From 242d00cd34534e7a93cb6f6b02cf4652bdd1210e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 24 Oct 2026 23:57:17 -0700 Subject: [PATCH 001/222] fix(clients): threads no longer flash in the inbox before they settle (#24760) With the Working shelf on, a thread that stops working stays there for 2s. A server auto-settle in that window sends it straight to Settled, so threads no longer flash in the inbox and vanish. "Thread completed" waits the same 2s and fires once. The server now settles fast enough to land inside the window: it reads a pull request when a shell merge or close command ends, sweeps a thread when its background work ends, and runs per-thread settle checks in parallel, one per thread at a time. Takes over #22458. Co-authored-by: Omar <76748259+omar9848492@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../src/features/threads/threadListV2.test.ts | 31 ++- .../src/features/threads/threadListV2.ts | 3 +- .../src/features/threads/useThreadListV2.ts | 16 +- .../PullRequestSyncReactor.test.ts | 90 ++++++- .../PullRequestSyncReactor.ts | 30 ++- .../ThreadSettlementService.test.ts | 159 +++++++++++ .../ThreadSettlementService.ts | 71 ++++- apps/web/src/components/Sidebar.tsx | 29 ++- ...ficationCoordinator.badge.browser.test.tsx | 28 +- ...hreadNotificationCoordinator.logic.test.ts | 193 ++++++++++++++ .../ThreadNotificationCoordinator.logic.ts | 78 ++++++ .../ThreadNotificationCoordinator.tsx | 246 ++++++++++-------- .../src/state/threadInbox.test.ts | 188 ++++++++++++- .../client-runtime/src/state/threadInbox.ts | 162 ++++++++++-- 14 files changed, 1137 insertions(+), 187 deletions(-) create mode 100644 apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts create mode 100644 apps/web/src/components/ThreadNotificationCoordinator.logic.ts diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 58d06bd5e696d..cb23296d41c80 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,4 +1,5 @@ import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox"; import { presentThreadShell } from "@t3tools/client-runtime/state/models"; import * as DateTime from "effect/DateTime"; import * as Schema from "effect/Schema"; @@ -24,7 +25,7 @@ import { RunId, ThreadId, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures"; @@ -1562,6 +1563,34 @@ describe("Working section beta", () => { expect(off.workingCount).toBe(0); }); + it("keeps an inactive row in Working during the shared grace and returns it on expiry", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); + const tracker = createInboxReturnTracker(); + const working = makeThread({ id: ThreadId.make("grace"), title: "grace", runtime: running }); + const inactive = { ...working, runtime: null }; + const render = () => + build({ + threads: [inactive], + workingShelfExpanded: true, + isWorking: tracker.isWorking, + inboxReturnAt: tracker.returnedAt, + }); + try { + tracker.observe([working]); + tracker.observe([inactive]); + expect(render().workingCount).toBe(1); + expect(render().items[0]?.thread.runtime).toBeNull(); + vi.advanceTimersByTime(2_000); + expect(render().workingCount).toBe(0); + expect(ids(render())).toEqual(["grace"]); + expect(tracker.returnedAt(inactive)).toBe(Date.parse(NOW) + 2_000); + } finally { + tracker.observe(null); + vi.useRealTimers(); + } + }); + it("orders the inbox by the latest return this device observed", () => { const layout = build({ inboxReturnAt: (thread) => diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 2486d33fee89f..424bd792c40d2 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -577,6 +577,7 @@ export function buildThreadListV2Items(input: { readonly now: string; readonly workingShelfEnabled?: boolean; readonly workingShelfExpanded?: boolean; + readonly isWorking?: (thread: EnvironmentThreadShell) => boolean; readonly inboxReturnAt?: (thread: EnvironmentThreadShell) => number | undefined; readonly snoozedShelfExpanded?: boolean; readonly settledShelfExpanded?: boolean; @@ -659,7 +660,7 @@ export function buildThreadListV2Items(input: { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); - } else if (workingShelfEnabled && isThreadWorking(thread)) { + } else if (workingShelfEnabled && (input.isWorking ?? isThreadWorking)(thread)) { working.push(thread); } else { active.push(thread); diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 12d861d193a7d..2a2ab54033ea8 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -1,5 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { useMemo } from "react"; +import { useMemo, useSyncExternalStore } from "react"; import { scopedProjectKey } from "../../lib/scopedEntities"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -54,10 +54,15 @@ export function useThreadListV2(input: { readonly selectedThreadKey: string | null; }) { const { threads, workingShelfEnabled } = input; - // Observed once per change to the threads: that is when a thread can stop working. - const inboxReturnAt = useMemo( + // A thread can stop working when the threads change, or when its exit grace expires. + const inboxVersion = useSyncExternalStore( + threadListInboxReturns.subscribe, + threadListInboxReturns.getSnapshot, + ); + const inbox = useMemo( () => threadListInboxReturns.observe(workingShelfEnabled ? threads : null), - [threads, workingShelfEnabled], + // oxlint-disable-next-line react/memo-dependencies -- the version signals an expired grace + [threads, workingShelfEnabled, inboxVersion], ); // Up/down menu availability for every card, computed once per section per // rebuild (see computeThreadMoveAvailability): per-thread planner calls made @@ -97,7 +102,8 @@ export function useThreadListV2(input: { now: input.now, workingShelfEnabled, workingShelfExpanded: input.workingShelfExpanded, - inboxReturnAt, + inboxReturnAt: inbox.returnedAt, + isWorking: inbox.isWorking, snoozedShelfExpanded: input.snoozedShelfExpanded, settledShelfExpanded: input.settledShelfExpanded, selectedThreadKey: input.selectedThreadKey, diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 184a0295b653b..b978f211589b1 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1340,7 +1340,7 @@ describe("PullRequestSyncReactor", () => { "gh pr merge 7 --squash 2>&1 | tail -3", "cafe pr merge 7 --squash", "az repos pr update --id 7 --status completed", - ])("reads open links fresh only when a run that ran `%s` ends", (mergeCommand) => + ])("reads open links fresh only for a thread that ran `%s`", (mergeCommand) => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); @@ -1392,4 +1392,92 @@ describe("PullRequestSyncReactor", () => { }), ), ); + + it.effect("reads open links again when the run ends, after `merge --auto` left them open", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make("open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }), + ]), + summary: (input) => + Ref.get(state).pipe( + Effect.map((current) => + makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}), + ), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Queue.offer( + fixture.domainEvents, + commandRan(ThreadId.make("agent"), "gh pr merge 7 --auto"), + ); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + yield* Ref.set(state, "merged"); + yield* Ref.set(fixture.syncCommands, []); + + yield* Queue.offer(fixture.domainEvents, runUpdated(ThreadId.make("agent"), "completed")); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => [ + command.number, + command.snapshot.state, + ]), + [[7, "merged"]], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("reads open links fresh as soon as a merge command ends, before its run ends", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make("open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }), + ]), + summary: (input) => + Ref.get(state).pipe( + Effect.map((current) => + makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}), + ), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Ref.set(state, "merged"); + yield* Ref.set(fixture.summaryCalls, []); + + yield* Queue.offer( + fixture.domainEvents, + commandRan(ThreadId.make("agent"), "gh pr merge 7"), + ); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => [ + command.number, + command.snapshot.state, + ]), + [[7, "merged"]], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); }); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index e2be84c5663db..fb9fa25c6e1bf 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -1,5 +1,6 @@ import { CommandId, + isOrchestrationV2WorkActive, type PullRequestSummary, type ThreadId, type ThreadPullRequestKey, @@ -481,7 +482,8 @@ export const make = Effect.gen(function* () { }).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), ); - const closeCommandThreads = new Set(); + // Threads that ran a close command this run, with the commands already read for. + const closeCommands = new Map>(); const refreshOpenLinks = (threadId: ThreadId) => projections.getThreadsWithPullRequests(threadId).pipe( Effect.flatMap((threads) => @@ -525,20 +527,22 @@ export const make = Effect.gen(function* () { { discard: true }, ); // An agent can merge or close its pull request from a shell with its host's CLI, which - // sends no merge notification. When a run that ran such a command ends, read the - // thread's open links fresh, so settlement does not wait for the next sweep and the - // cached summary. Other runs add no host reads. - case "turn-item.updated": - if ( - event.payload.type === "command_execution" && - isChangeRequestCloseCommand(event.payload.input) - ) { - closeCommandThreads.add(event.threadId); + // sends no merge notification. Read the thread's open links fresh once that command + // ends, while the agent usually still replies, so settlement can land before the run + // ends. Read again when the run ends, since `merge --auto` returns before it merges. + case "turn-item.updated": { + const item = event.payload; + if (item.type !== "command_execution" || !isChangeRequestCloseCommand(item.input)) { + return Effect.void; } - return Effect.void; + const ended = closeCommands.get(event.threadId) ?? new Set(); + closeCommands.set(event.threadId, ended); + if (isOrchestrationV2WorkActive(item.status) || ended.has(item.id)) return Effect.void; + ended.add(item.id); + return refreshOpenLinks(event.threadId); + } case "run.updated": - return isTerminalRunStatus(event.payload.status) && - closeCommandThreads.delete(event.threadId) + return isTerminalRunStatus(event.payload.status) && closeCommands.delete(event.threadId) ? refreshOpenLinks(event.threadId) : Effect.void; default: diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index c16a012945c5d..50ac83b814626 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -6,6 +6,7 @@ import { ProjectId, EventId, ProviderInstanceId, + RunId, ThreadId, type OrchestrationProjectShell, type OrchestrationV2AppThread, @@ -892,6 +893,164 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); + it.effect("settles a merged thread when its background work ends, during a slow full sweep", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const periodicLookupStarted = yield* Deferred.make(); + const releasePeriodicLookup = yield* Deferred.make(); + const settled = yield* Deferred.make(); + const branchLookupCount = yield* Ref.make(0); + const mergedLink: NonNullable[number] = { + ...persistedLink("open", DateTime.makeUnsafe("2026-08-20T00:00:00.000Z")), + snapshot: { + state: "merged", + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: NOW, + }, + }; + const waiting = makeThread("waiting-on-subagent", { + pullRequests: [mergedLink], + pendingBackgroundTasks: [{ taskId: "child", kind: "subagent", description: "Review" }], + }); + const slow = makeThread("slow-periodic-lookup", { + branch: "another-feature", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([waiting, slow]), + branchPullRequest: () => + Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.succeed(makeBranchPullRequest("open")) + : Deferred.succeed(periodicLookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePeriodicLookup)), + Effect.as(makeBranchPullRequest("open")), + ), + ), + ), + onDispatch: () => Deferred.succeed(settled, undefined), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); + yield* Deferred.await(periodicLookupStarted); + + yield* Ref.set( + fixture.snapshots, + makeSnapshot([{ ...waiting, pendingBackgroundTasks: [] }, slow]), + ); + yield* fixture.publishEvent({ + type: "run.background-work-cancelled", + id: EventId.make("event:background-ended"), + threadId: waiting.id, + occurredAt: DateTime.makeUnsafe(NOW), + payload: { runId: RunId.make("run:waiting"), restartCancelledBackgroundWork: [] }, + }); + yield* Deferred.await(settled); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [waiting.id], + ); + yield* Deferred.succeed(releasePeriodicLookup, undefined); + yield* reactor.drain; + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("settles a stopped thread while another thread's lookup is slow", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const slowLookupStarted = yield* Deferred.make(); + const releaseSlowLookup = yield* Deferred.make(); + const settled = yield* Deferred.make(); + const branchLookupCount = yield* Ref.make(0); + const recent = DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"); + const slow = makeThread("slow-lookup", { branch: "feature", latestUserMessageAt: recent }); + const merged = makeThread("merged", { + latestUserMessageAt: recent, + pullRequests: [ + { + ...persistedLink("open", recent), + snapshot: { + state: "merged", + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: NOW, + }, + }, + ], + pendingBackgroundTasks: [{ taskId: "child", kind: "subagent", description: "Review" }], + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([slow, merged]), + branchPullRequest: () => + Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.succeed(makeBranchPullRequest("open")) + : Deferred.succeed(slowLookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSlowLookup)), + Effect.as(makeBranchPullRequest("open")), + ), + ), + ), + onDispatch: () => Deferred.succeed(settled, undefined), + }); + const backgroundEnded = (threadId: ThreadId): OrchestrationV2DomainEvent => ({ + type: "run.background-work-cancelled", + id: EventId.make(`event:background-ended:${threadId}`), + threadId, + occurredAt: DateTime.makeUnsafe(NOW), + payload: { runId: RunId.make(`run:${threadId}`), restartCancelledBackgroundWork: [] }, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + yield* fixture.publishEvent(backgroundEnded(slow.id)); + yield* Deferred.await(slowLookupStarted); + // More events for the slow thread coalesce into one more pass, not more sweeps. + for (let index = 0; index < 9; index++) { + yield* fixture.publishEvent(backgroundEnded(slow.id)); + } + + yield* Ref.set( + fixture.snapshots, + makeSnapshot([slow, { ...merged, pendingBackgroundTasks: [] }]), + ); + yield* fixture.publishEvent(backgroundEnded(merged.id)); + yield* Deferred.await(settled); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [merged.id], + ); + yield* Deferred.succeed(releaseSlowLookup, undefined); + yield* reactor.drain; + // The startup sweep, the first per-thread sweep, and one coalesced rerun. + assert.strictEqual(yield* Ref.get(branchLookupCount), 3); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect( "settles branch threads on a pull request merge without waiting for the next sweep", () => diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index bd5b7d346fb12..a14601ae84054 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -1,8 +1,12 @@ -import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { + backgroundWorkHoldsCompletion, + turnItemUpdateCanEndBackgroundWork, +} from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import { CommandId, + isOrchestrationV2WorkActive, type ThreadId, type OrchestrationV2DomainEvent, type OrchestrationV2ThreadShell, @@ -13,9 +17,11 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FiberSet from "effect/FiberSet"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; +import * as Semaphore from "effect/Semaphore"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -523,9 +529,35 @@ export const make = Effect.gen(function* () { }), ), ); - const worker = yield* makeDrainableWorker((threadId: ThreadId | undefined) => - runSweep(null, threadId), - ); + // A thread that just stopped must not wait behind a full sweep or another thread's host + // reads: clients hold it in Working only briefly, so a late settle makes it appear and then vanish. + const fullSweeps = yield* makeDrainableWorker(() => runSweep(null)); + const threadSweeps = yield* FiberSet.make(); + const threadSweepSlots = yield* Semaphore.make(8); + // One sweep per thread at a time; events during a running sweep coalesce into one more pass. + const threadSweepStates = new Map(); + const sweepThread = ( + threadId: ThreadId, + ): Effect.Effect>> => + Effect.sync(() => threadSweepStates.set(threadId, "running")).pipe( + Effect.andThen(runSweep(null, threadId)), + threadSweepSlots.withPermits(1), + Effect.andThen( + Effect.suspend(() => { + if (threadSweepStates.get(threadId) === "rerun") return sweepThread(threadId); + threadSweepStates.delete(threadId); + return Effect.void; + }), + ), + ); + const enqueueThread = (threadId: ThreadId) => + Effect.suspend(() => { + const state = threadSweepStates.get(threadId); + if (state === "running") threadSweepStates.set(threadId, "rerun"); + if (state !== undefined) return Effect.void; + threadSweepStates.set(threadId, "queued"); + return sweepThread(threadId).pipe(FiberSet.run(threadSweeps), Effect.asVoid); + }); // Skip settle scripts in shared checkouts where other threads may still work. const cleanUpSettledThread = Effect.fn("ThreadSettlementServiceV2.cleanUpSettledThread")( @@ -572,16 +604,30 @@ export const make = Effect.gen(function* () { case "thread.pull-request-link-updated": case "thread.pull-request-synced": case "provider-session.detached": - return worker.enqueue(event.threadId); + case "run.background-work-cancelled": + return enqueueThread(event.threadId); + // Sweep only when the work can have ended: progress updates cannot settle anything. + case "subagent.updated": + return isOrchestrationV2WorkActive(event.payload.status) + ? Effect.void + : enqueueThread(event.threadId); + case "provider-thread.updated": + return backgroundWorkHoldsCompletion(event.payload.pendingBackgroundTasks ?? []) + ? Effect.void + : enqueueThread(event.threadId); + case "turn-item.updated": + return turnItemUpdateCanEndBackgroundWork(event.payload) + ? enqueueThread(event.threadId) + : Effect.void; case "provider-session.updated": return event.payload.status !== "starting" && event.payload.status !== "running" - ? worker.enqueue(event.threadId) + ? enqueueThread(event.threadId) : Effect.void; case "run.updated": return ["completed", "interrupted", "failed", "cancelled", "rolled_back"].includes( event.payload.status, ) - ? worker.enqueue(event.threadId) + ? enqueueThread(event.threadId) : Effect.void; default: return Effect.void; @@ -598,8 +644,8 @@ export const make = Effect.gen(function* () { let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); yield* forkParked( Effect.gen(function* () { - yield* worker.enqueue(undefined); - yield* worker.drain; + yield* fullSweeps.enqueue(undefined); + yield* fullSweeps.drain; }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), ); yield* forkParked( @@ -609,7 +655,7 @@ export const make = Effect.gen(function* () { return Effect.void; } lastSettlementSettings = key; - return worker.enqueue(undefined); + return fullSweeps.enqueue(undefined); }), ); yield* forkParked(Stream.runForEach(mergedPullRequests, (event) => runSweep(event))); @@ -622,7 +668,10 @@ export const make = Effect.gen(function* () { ); }); - return { start, drain: worker.drain } satisfies ThreadSettlementServiceV2["Service"]; + return { + start, + drain: Effect.andThen(fullSweeps.drain, FiberSet.awaitEmpty(threadSweeps)), + } satisfies ThreadSettlementServiceV2["Service"]; }); export const layer = Layer.effect(ThreadSettlementServiceV2, make); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8d40d5f01a48d..2101d2ddae557 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -28,7 +28,10 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; -import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox"; +import { + createInboxReturnTracker, + isThreadWorking, +} from "@t3tools/client-runtime/state/thread-inbox"; import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; import { nextShownOrder, @@ -61,6 +64,7 @@ import { useReducer, useRef, useState, + useSyncExternalStore, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, @@ -154,7 +158,6 @@ import { filterSidebarProjectScopeItems, firstValidTimestampMs, isSidebarNestedLinkClick, - isSidebarThreadWorking, isTrailingDoubleClick, orderItemsByPreferredIds, planSidebarThreadDrop, @@ -302,6 +305,9 @@ interface SidebarThreadPartitionInput { readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly workingShelfEnabled: boolean; + // Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend + // on the clock, so the minute tick, the precise snooze-wake tick and the inbox + // tracker's expiry version are inputs: a new key repartitions. readonly clockKey: string; } @@ -375,6 +381,10 @@ function useSidebarThreads(input: { ), ); const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); + const inboxReturnsVersion = useSyncExternalStore( + inboxReturns.subscribe, + inboxReturns.getSnapshot, + ); const nowMinute = useNowMinute(); // Minute-quantized clocks would delay second-precise snooze wakes by up to a minute. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); @@ -414,7 +424,7 @@ function useSidebarThreads(input: { serverConfigs, optimisticDrop, workingShelfEnabled, - clockKey: `${nowMinute}:${snoozeWakeTick}`, + clockKey: `${nowMinute}:${snoozeWakeTick}:${inboxReturnsVersion}`, }); const partitionNextWakeAt = partition.snoozedThreads[0]?.snoozedUntil ?? null; if (partitionNextWakeAt !== nextSnoozeWakeAt) setNextSnoozeWakeAt(partitionNextWakeAt); @@ -506,12 +516,15 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const { threads, scopedProjectKeys, serverConfigs, optimisticDrop, workingShelfEnabled } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); - inboxReturns.observe(workingShelfEnabled ? threads : null); + const inboxObservation = inboxReturns.observe(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; - const inbox = (thread: EnvironmentThreadShell) => - workingShelfEnabled && isSidebarThreadWorking(thread) ? working : active; + const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => + workingShelfEnabled && + (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) + ? working + : active; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const draggable = new Set(); @@ -536,7 +549,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { ? pinned : optimisticDrop.section === "settled" ? settled - : inbox(projected) + : inbox(projected, true) ).push( optimisticDrop.clearsSnooze ? projected @@ -561,7 +574,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { // Capability gates dragging only; mixed server versions must preserve the same pinned order across clients. const sortedPinned = sortPinnedThreadsForSidebar(pinned); const sortedActive = workingShelfEnabled - ? sortInboxThreadsByReturn(active, inboxReturns.returnedAt) + ? sortInboxThreadsByReturn(active, inboxObservation.returnedAt) : sortThreadsForSidebar(active); return { pinnedThreads: diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx index 145d6f740f02c..fe0e21190d02b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx @@ -1,11 +1,12 @@ import { RegistryContext } from "@effect/atom-react"; +import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; import { EnvironmentId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; -import { render, stubGlobal, type Screen } from "../../test/render"; +import { fakeTimers, render, stubGlobal, type Screen } from "../../test/render"; const state = vi.hoisted(() => ({ mode: "notifications", @@ -235,6 +236,31 @@ it.each([ latestTurn: { turnId: "turn", state: status, completedAt: "2026-09-13T07:01:00.000Z" }, }), ); + using _timers = fakeTimers(); await screen.rerender(app.ui()); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); expect(TestNotification.sent.map((notification) => notification.title)).toEqual(titles); }); + +it("holds a completion through a continuation gap and notifies once it settles", async () => { + await using app = setup(); + await app.render(); + using _timers = fakeTimers(); + const finished = (turnId: string, completedAt: string) => ({ + latestTurn: { turnId, state: "completed", completedAt }, + }); + state.shells.set("one", shell(finished("turn-1", "2026-09-13T07:00:01.000Z"))); + await app.render(); + state.shells.set( + "one", + shell({ latestTurn: { turnId: "turn-2", state: "running", completedAt: null } }), + ); + await app.render(); + state.shells.set("one", shell(finished("turn-2", "2026-09-13T07:00:02.000Z"))); + await app.render(); + expect(TestNotification.sent).toEqual([]); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS - 1); + expect(TestNotification.sent).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts new file mode 100644 index 0000000000000..2bba09f54dcb1 --- /dev/null +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -0,0 +1,193 @@ +import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + createThreadNotificationGate, + type ThreadNotificationObservation, +} from "./ThreadNotificationCoordinator.logic"; + +const working: ThreadNotificationObservation = { + attention: null, + settled: false, + completedAt: null, +}; +const completed = (at: number): ThreadNotificationObservation => ({ + attention: null, + settled: true, + completedAt: at, +}); +const visible = { archived: false, quiet: false }; + +function harness() { + const notify = vi.fn(); + const gate = createThreadNotificationGate(notify); + let prior: ThreadNotificationObservation | undefined; + const observe = (next: ThreadNotificationObservation, options = visible) => { + gate.observe("thread", prior, next, options); + prior = next; + }; + return { gate, notify, observe }; +} + +describe("createThreadNotificationGate", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("drops a completion when the thread resumes within the grace", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS - 1); + observe(working); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify).not.toHaveBeenCalled(); + }); + + it("notifies a real completion once after the grace", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS - 1); + expect(notify).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(notify.mock.calls).toEqual([["thread", "completion"]]); + observe({ ...completed(1) }); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it("notifies attention at once and replaces a pending completion", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(500); + observe({ attention: "run-1:approval", settled: false, completedAt: null }); + expect(notify.mock.calls).toEqual([["thread", "input"]]); + vi.advanceTimersByTime(60_000); + // The request resolves and the same run reads as completed again. + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify.mock.calls).toEqual([["thread", "input"]]); + }); + + it("notifies one completion across continuation gaps", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(300); + observe(working); + vi.advanceTimersByTime(5_000); + observe(completed(2)); + vi.advanceTimersByTime(300); + observe(working); + vi.advanceTimersByTime(5_000); + observe(completed(3)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); + expect(notify.mock.calls).toEqual([["thread", "completion"]]); + }); + + it("keeps a completion interrupted by attention after a working gap from firing", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(500); + observe(working); + observe({ attention: "run-1:approval", settled: false, completedAt: null }); + vi.advanceTimersByTime(60_000); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify.mock.calls).toEqual([["thread", "input"]]); + }); + + it("adds nothing when attention follows a completion that already fired", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); + observe({ attention: "run-1:limited", settled: false, completedAt: null }); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify.mock.calls).toEqual([ + ["thread", "completion"], + ["thread", "input"], + ]); + }); + + it("still notifies a newer completion after attention", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(500); + observe({ attention: "run-2:approval", settled: false, completedAt: null }); + observe(working); + observe(completed(2)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); + expect(notify.mock.calls).toEqual([ + ["thread", "input"], + ["thread", "completion"], + ]); + }); + + it("ignores unrelated updates while a completion waits", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(1_500); + observe({ ...completed(1) }); + vi.advanceTimersByTime(500); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it("notifies a run that settles again after a brief runtime blip", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(500); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); + expect(notify.mock.calls).toEqual([["thread", "completion"]]); + }); + + it("restarts the grace when a newer completion replaces a pending one", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(1_500); + observe(completed(2)); + vi.advanceTimersByTime(1_500); + expect(notify).not.toHaveBeenCalled(); + vi.advanceTimersByTime(500); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it("drops a pending completion on archive, stop request, removal or clear", () => { + for (const end of [ + (h: ReturnType) => h.observe(completed(1), { ...visible, archived: true }), + (h: ReturnType) => + h.observe({ ...completed(1) }, { ...visible, quiet: true }), + (h: ReturnType) => h.gate.retain(new Set()), + (h: ReturnType) => h.gate.clear(), + ]) { + const h = harness(); + h.observe(working); + h.observe(completed(1)); + end(h); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(h.notify).not.toHaveBeenCalled(); + } + }); + + it("never notifies a stopped run or the first observation", () => { + const { notify, observe } = harness(); + observe(completed(1)); + observe(working); + observe(completed(2), { ...visible, quiet: true }); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts new file mode 100644 index 0000000000000..77edccfbe7741 --- /dev/null +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -0,0 +1,78 @@ +import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; + +export type ThreadNotificationKind = "input" | "completion"; + +export interface ThreadNotificationObservation { + /** Run and status key while the thread needs attention, otherwise null. */ + readonly attention: string | null; + /** The thread is idle and its latest run completed. */ + readonly settled: boolean; + /** When the latest run completed, if it did. */ + readonly completedAt: number | null; +} + +/** + * Attention notifies at once. Completion waits out the Working shelf's exit grace, + * because an orchestrator run can end moments before its continuation starts. Any + * observation that is not settled during the wait cancels it. + */ +export function createThreadNotificationGate( + notify: (key: Key, kind: ThreadNotificationKind) => void, +) { + const pending = new Map }>(); + // The newest completion already notified, or deliberately skipped. + const handled = new Map(); + // The newest completion seen settled, kept while the thread works or waits. + const lastSettled = new Map(); + const cancel = (key: Key) => { + clearTimeout(pending.get(key)?.timer); + pending.delete(key); + }; + const skip = (key: Key, at: number | null) => { + if (at !== null && at > (handled.get(key) ?? -Infinity)) handled.set(key, at); + }; + return { + observe( + key: Key, + prior: ThreadNotificationObservation | undefined, + next: ThreadNotificationObservation, + options: { readonly archived: boolean; readonly quiet: boolean }, + ): void { + if (next.settled && next.completedAt !== null) lastSettled.set(key, next.completedAt); + if (!next.settled || options.archived || options.quiet) cancel(key); + // The first sighting, archived threads, and stopped or silent runs never notify completion. + if (!prior || options.archived || options.quiet) skip(key, next.completedAt); + if (!prior || options.archived) return; + if (next.attention && next.attention !== prior.attention) { + // Attention replaces the completion before it, so that run must not notify later. + skip(key, lastSettled.get(key) ?? null); + cancel(key); + notify(key, "input"); + return; + } + const at = next.completedAt; + if (!next.settled || at === null || at <= (handled.get(key) ?? -Infinity)) return; + if (pending.get(key)?.at === at) return; + cancel(key); + pending.set(key, { + at, + timer: setTimeout(() => { + pending.delete(key); + handled.set(key, at); + notify(key, "completion"); + }, WORKING_EXIT_GRACE_MS), + }); + }, + /** Forgets threads that left the snapshot. */ + retain(keys: ReadonlySet): void { + for (const key of pending.keys()) if (!keys.has(key)) cancel(key); + for (const key of handled.keys()) if (!keys.has(key)) handled.delete(key); + for (const key of lastSettled.keys()) if (!keys.has(key)) lastSettled.delete(key); + }, + clear(): void { + for (const key of pending.keys()) cancel(key); + handled.clear(); + lastSettled.clear(); + }, + }; +} diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 9769fbce75e62..1d6f37ed351d8 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -1,4 +1,5 @@ import { + type EnvironmentThreadShell, type PendingSubagentRequest, presentThreadShell, } from "@t3tools/client-runtime/state/models"; @@ -13,7 +14,7 @@ import { MessageCircleQuestionIcon, ShieldQuestionIcon, } from "lucide-react"; -import { useEffect, useRef } from "react"; +import { useEffect, useEffectEvent, useRef } from "react"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { useEnvironmentIds } from "../state/environments"; @@ -26,7 +27,12 @@ import { threadNotificationTag, unlockNotificationAudio, } from "../threadNotifications"; -import { resolveSidebarThreadStatus } from "./Sidebar.logic"; +import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "./Sidebar.logic"; +import { + createThreadNotificationGate, + type ThreadNotificationKind, + type ThreadNotificationObservation, +} from "./ThreadNotificationCoordinator.logic"; import { toastManager } from "./ui/toast"; export function ThreadNotificationCoordinator() { @@ -93,11 +99,11 @@ export function ThreadNotificationCoordinator() { )); } -interface NotificationState { +interface NotificationState extends ThreadNotificationObservation { readonly raw: OrchestrationV2ThreadShell; readonly subagentRequest: PendingSubagentRequest | null; - readonly attention: string | null; - readonly completion: number | null; + readonly thread: EnvironmentThreadShell; + readonly status: SidebarThreadStatus; } function EnvironmentNotifications({ @@ -120,13 +126,116 @@ function EnvironmentNotifications({ }); const previous = useRef(new Map()); + // Completions fire from a timer, so read settings, focus and route when presenting. + const present = useEffectEvent((threadId: ThreadId, kind: ThreadNotificationKind) => { + const entry = previous.current.get(threadId); + if (!entry) return; + const { thread, status } = entry; + const title = + kind === "completion" + ? "Thread completed" + : status === "approval" + ? "Approval needed" + : status === "limited" + ? "Usage limit reached" + : status === "failed" + ? "Thread failed" + : status === "request" + ? "Guest request" + : "Input needed"; + if (hasNotificationSound(mode)) { + void playNotificationSound(kind, () => + hasNotificationSound(getClientSettings().notificationMode), + ); + } + if ( + inAppNotificationsEnabled && + document.visibilityState === "visible" && + document.hasFocus() && + (activeEnvironmentId !== environmentId || activeThreadId !== thread.id) + ) { + const toastId = toastManager.add({ + type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning", + title, + description: thread.title, + data: { + hideCopyButton: true, + leadingIcon: + kind === "completion" ? ( + + ) : status === "approval" ? ( + + ) : status === "failed" ? ( + + ) : ( + + ), + }, + actionProps: { + children: "Open thread", + onClick: () => { + toastManager.close(toastId); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: thread.id }, + }); + }, + }, + }); + return; + } + if ( + !hasDesktopNotifications(mode) || + (document.visibilityState === "visible" && document.hasFocus()) || + typeof Notification === "undefined" || + Notification.permission !== "granted" + ) + return; + try { + const notification = new Notification(title, { + body: thread.title, + tag: threadNotificationTag(environmentId, thread.id), + silent: true, + }); + onNotification(environmentId, notification); + notification.addEventListener("click", () => { + notification.close(); + window.focus(); + void window.desktopBridge?.revealWindow?.().catch(() => undefined); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: thread.id }, + }); + }); + } catch { + // Some browsers expose Notification but reject desktop presentation. + } + }); + const gate = useRef>>(null); useEffect(() => { + const created = createThreadNotificationGate((threadId, kind) => + present(threadId, kind), + ); + gate.current = created; + return () => { + created.clear(); + gate.current = null; + // A new gate has no pending completions, so forget the states they compared against. + previous.current.clear(); + }; + }, []); + + useEffect(() => { + const pendingCompletions = gate.current; + if (!pendingCompletions) return; if (threads === null) { + pendingCompletions.clear(); previous.current.clear(); return; } const next = new Map(); const subagentRequests = pendingSubagentRequestsByThread(threads); + const changed: Array<[NotificationState | undefined, NotificationState]> = []; for (const rawThread of threads) { if (rawThread.lineage.relationshipToParent === "subagent") continue; const prior = previous.current.get(rawThread.id); @@ -147,115 +256,28 @@ function EnvironmentNotifications({ ? `${thread.latestRun?.runId ?? ""}:${status}` : null; const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); - const completion = - status === "ready" && - thread.latestRun?.status === "completed" && - Number.isFinite(completedAt) - ? completedAt - : (prior?.completion ?? null); - next.set(thread.id, { raw: rawThread, subagentRequest, attention, completion }); - if (!prior || thread.archivedAt !== null) continue; - const kind = - attention && attention !== prior.attention - ? "input" - : !rawThread.latestRunStopRequested && - !rawThread.latestRunSilent && - completion !== null && - (prior.completion === null || completion > prior.completion) - ? "completion" - : null; - if (!kind) continue; - const title = - kind === "completion" - ? "Thread completed" - : status === "approval" - ? "Approval needed" - : status === "limited" - ? "Usage limit reached" - : status === "failed" - ? "Thread failed" - : status === "request" - ? "Guest request" - : "Input needed"; - if (hasNotificationSound(mode)) { - void playNotificationSound(kind, () => - hasNotificationSound(getClientSettings().notificationMode), - ); - } - if ( - inAppNotificationsEnabled && - document.visibilityState === "visible" && - document.hasFocus() && - (activeEnvironmentId !== environmentId || activeThreadId !== thread.id) - ) { - const toastId = toastManager.add({ - type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning", - title, - description: thread.title, - data: { - hideCopyButton: true, - leadingIcon: - kind === "completion" ? ( - - ) : status === "approval" ? ( - - ) : status === "failed" ? ( - - ) : ( - - ), - }, - actionProps: { - children: "Open thread", - onClick: () => { - toastManager.close(toastId); - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId: thread.id }, - }); - }, - }, - }); - continue; - } - if ( - !hasDesktopNotifications(mode) || - (document.visibilityState === "visible" && document.hasFocus()) || - typeof Notification === "undefined" || - Notification.permission !== "granted" - ) - continue; - try { - const notification = new Notification(title, { - body: thread.title, - tag: threadNotificationTag(environmentId, thread.id), - silent: true, - }); - onNotification(environmentId, notification); - notification.addEventListener("click", () => { - notification.close(); - window.focus(); - void window.desktopBridge?.revealWindow?.().catch(() => undefined); - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId: thread.id }, - }); - }); - } catch { - // Some browsers expose Notification but reject desktop presentation. - } + const settled = status === "ready" && thread.latestRun?.status === "completed"; + const state = { + raw: rawThread, + subagentRequest, + thread, + status, + attention, + settled, + completedAt: settled && Number.isFinite(completedAt) ? completedAt : null, + }; + next.set(thread.id, state); + changed.push([prior, state]); } previous.current = next; - }, [ - activeEnvironmentId, - activeThreadId, - environmentId, - inAppNotificationsEnabled, - mode, - navigate, - onNotification, - threads, - ]); + pendingCompletions.retain(new Set(next.keys())); + for (const [prior, state] of changed) { + pendingCompletions.observe(state.thread.id, prior, state, { + archived: state.thread.archivedAt !== null, + quiet: state.raw.latestRunStopRequested === true || state.raw.latestRunSilent === true, + }); + } + }, [environmentId, threads]); return null; } diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index f5675d3a72eef..35c7fa8355e39 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -1,7 +1,12 @@ import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { createInboxReturnTracker, sortWorkingThreadsBySend } from "./threadInbox.ts"; +import { + createInboxReturnTracker, + isThreadWorking, + sortInboxThreadsByReturn, + sortWorkingThreadsBySend, +} from "./threadInbox.ts"; const environmentId = EnvironmentId.make("environment-1"); @@ -30,18 +35,185 @@ function thread(id: string, working: boolean) { } describe("createInboxReturnTracker", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + }); + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); it("stamps a thread when it stops working, but never on the first observation", () => { const tracker = createInboxReturnTracker(); const first = tracker.observe([thread("a", true), thread("b", false)]); - expect(first(thread("a", true))).toBeUndefined(); - expect(first(thread("b", false))).toBeUndefined(); + expect(first.returnedAt(thread("a", true))).toBeUndefined(); + expect(first.returnedAt(thread("b", false))).toBeUndefined(); + const held = tracker.observe([thread("a", false), thread("b", false)]); + vi.advanceTimersByTime(2_000); const second = tracker.observe([thread("a", false), thread("b", false)]); - expect(second(thread("a", false))).toBeDefined(); - expect(second(thread("b", false))).toBeUndefined(); - expect(tracker.returnedAt(thread("a", false))).toBe(second(thread("a", false))); + expect(second.returnedAt(thread("a", false))).toBeDefined(); + expect(second.returnedAt(thread("b", false))).toBeUndefined(); + expect(tracker.returnedAt(thread("a", false))).toBe(second.returnedAt(thread("a", false))); // An earlier observation keeps its answer, so lists built from it stay valid. - expect(first(thread("a", false))).toBeUndefined(); + expect(held.isWorking(thread("a", false))).toBe(true); + expect(held.returnedAt(thread("a", false))).toBeUndefined(); + }); + + it("holds only placement, then expires and notifies without another shell event", () => { + const tracker = createInboxReturnTracker(); + const notify = vi.fn(); + const unsubscribe = tracker.subscribe(notify); + tracker.observe([thread("a", true)]); + tracker.observe([thread("a", false)]); + expect(isThreadWorking(thread("a", false))).toBe(false); + expect(tracker.isWorking(thread("a", false))).toBe(true); + vi.advanceTimersByTime(1_999); + tracker.observe([thread("a", false)]); + expect(tracker.isWorking(thread("a", false))).toBe(true); + expect(tracker.returnedAt(thread("a", false))).toBeUndefined(); + vi.advanceTimersByTime(1); + expect(tracker.isWorking(thread("a", false))).toBe(false); + expect(tracker.returnedAt(thread("a", false))).toBe(12_000); + expect(notify).toHaveBeenCalledTimes(1); + unsubscribe(); + }); + + it("cancels a transient return on resume and grants a fresh grace after the next finish", () => { + const tracker = createInboxReturnTracker(); + tracker.observe([thread("a", false)]); + expect(tracker.isWorking(thread("a", false))).toBe(false); + tracker.observe([thread("a", true)]); + expect(tracker.isWorking(thread("a", true))).toBe(true); + tracker.observe([thread("a", false)]); + vi.advanceTimersByTime(1_500); + tracker.observe([thread("a", true)]); + vi.advanceTimersByTime(1_000); + expect(tracker.returnedAt(thread("a", true))).toBeUndefined(); + tracker.observe([thread("a", false)]); + vi.advanceTimersByTime(1_999); + expect(tracker.isWorking(thread("a", false))).toBe(true); + vi.advanceTimersByTime(1); + expect(tracker.returnedAt(thread("a", false))).toBe(14_500); + }); + + it.each([ + { hasPendingApprovals: true }, + { hasPendingUserInput: true }, + { pendingSubagentRequest: "approval" as const }, + { pendingSubagentRequest: "user_input" as const }, + { pendingGuestMessageCount: 1 }, + { interactionMode: "plan" as const, hasActionableProposedPlan: true }, + { runtime: { ...thread("a", true).runtime!, status: "failed" as const, lastError: "failed" } }, + { pinnedAt: "2026-06-01T00:00:01Z" }, + { settledOverride: "settled" as const }, + { snoozedUntil: "2026-06-02T00:00:00Z" }, + { activeOrderKey: "manual-order" }, + ])("ends a grace immediately for attention or manual placement: %j", (change) => { + const tracker = createInboxReturnTracker(); + tracker.observe([thread("a", true)]); + tracker.observe([thread("a", false)]); + tracker.observe([{ ...thread("a", false), ...change }]); + expect(tracker.isWorking(thread("a", false))).toBe(false); + expect(tracker.returnedAt(thread("a", false))).toBe(10_000); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["interrupted", "cancelled", "rolled_back"] as const)( + "holds a %s run, because the server can still settle it", + (status) => { + const tracker = createInboxReturnTracker(); + const stopped = { + ...thread("a", false), + latestRun: { + runId: RunId.make("stopped"), + status, + requestedAt: null, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + }; + tracker.observe([thread("a", true)]); + tracker.observe([stopped]); + expect(tracker.isWorking(stopped)).toBe(true); + tracker.observe([{ ...stopped, settledOverride: "settled" as const }]); + expect(tracker.isWorking(stopped)).toBe(false); + }, + ); + + it("grants grace after a retained expired snooze", () => { + const tracker = createInboxReturnTracker(); + const retained = { + snoozedAt: "1970-01-01T00:00:01.000Z", + snoozedUntil: "1970-01-01T00:00:05.000Z", + latestRun: { + runId: RunId.make("completed-wake"), + status: "completed" as const, + requestedAt: "1970-01-01T00:00:02.000Z", + startedAt: "1970-01-01T00:00:02.000Z", + completedAt: "1970-01-01T00:00:09.000Z", + assistantMessageId: null, + }, + }; + const active = { ...thread("a", true), ...retained }; + const finished = { ...thread("a", false), ...retained }; + tracker.observe([active]); + tracker.observe([finished]); + expect(tracker.isWorking(finished)).toBe(true); + expect(tracker.returnedAt(finished)).toBeUndefined(); + vi.advanceTimersByTime(1_999); + expect(tracker.isWorking(finished)).toBe(true); + vi.advanceTimersByTime(1); + expect(tracker.isWorking(finished)).toBe(false); + expect(tracker.returnedAt(finished)).toBe(12_000); + }); + + it("does not grant grace to a future snooze after background completion", () => { + const tracker = createInboxReturnTracker(); + const retained = { + snoozedAt: "1970-01-01T00:00:01.000Z", + snoozedUntil: "1970-01-01T00:00:30.000Z", + }; + tracker.observe([{ ...thread("a", true), ...retained }]); + const finished = { ...thread("a", false), ...retained }; + tracker.observe([finished]); + expect(tracker.isWorking(finished)).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stamps only stable returns and sorts them in their actual return order", () => { + const tracker = createInboxReturnTracker(); + const a = { ...thread("a", false), createdAt: "1970-01-01T00:00:00Z" }; + const b = { ...thread("b", false), createdAt: "1970-01-01T00:00:00Z" }; + tracker.observe([thread("a", true), thread("b", true)]); + tracker.observe([a, thread("b", true)]); + vi.advanceTimersByTime(500); + tracker.observe([a, b]); + vi.advanceTimersByTime(1_500); + expect(tracker.returnedAt(a)).toBe(12_000); + expect(tracker.returnedAt(b)).toBeUndefined(); + vi.advanceTimersByTime(500); + expect(sortInboxThreadsByReturn([a, b], tracker.returnedAt).map((entry) => entry.id)).toEqual([ + "b", + "a", + ]); + }); + + it("cancels pending timers for removed threads and disabled shelves", () => { + const tracker = createInboxReturnTracker(); + const notify = vi.fn(); + tracker.subscribe(notify); + tracker.observe([thread("a", true)]); + tracker.observe([thread("a", false)]); + tracker.observe([]); + expect(vi.getTimerCount()).toBe(0); + tracker.observe([thread("a", true)]); + tracker.observe([thread("a", false)]); + tracker.observe(null); + vi.advanceTimersByTime(2_000); + expect(notify).not.toHaveBeenCalled(); + expect(tracker.returnedAt(thread("a", false))).toBeUndefined(); }); }); diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 8083dd045385b..de9cd822398d0 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -1,5 +1,7 @@ +// @effect-diagnostics globalTimers:off - Shelf placement timers run in web and native clients, outside an Effect runtime. import { threadRuntimeIsActive, type EnvironmentThreadShell } from "./models.ts"; import { toSortableTimestamp } from "./threadSort.ts"; +import { effectiveSnoozed } from "./threadSettled.ts"; type WorkingThreadInput = Pick< EnvironmentThreadShell, @@ -86,46 +88,154 @@ function sortNewestFirst + >; + +type ThreadKeyInput = Pick; + +/** + * How long a thread that stopped working keeps reading as working. An orchestrator + * run can end moments before its continuation starts, so clients wait this long + * before treating the thread as done. + */ +export const WORKING_EXIT_GRACE_MS = 2_000; + /** - * Keep one at module scope so the inbox order survives routes that unmount the - * list. Call `observe` with every thread shell whenever the shells change, or - * with null to reset while the beta is off. It returns the return times as of - * that observation, which stay fixed so a list built from them can be cached. + * Keep one at module scope so placement survives routes that unmount the list. Call + * `observe` when the shells or the snapshot version change, or with null while the beta + * is off. Its result stays fixed, so a list built from it can be cached. */ export function createInboxReturnTracker() { - const keyOf = (thread: Pick) => - `${thread.environmentId}:${thread.id}`; - let lastWorkingKeys: ReadonlySet | null = null; + const keyOf = (thread: ThreadKeyInput) => `${thread.environmentId}:${thread.id}`; + const states = new Map< + string, + { working: boolean; deadline: number | null; placement: string } + >(); const returns = new Map(); + const listeners = new Set<() => void>(); + let timer: ReturnType | undefined; + let version = 0; + const now = () => { + // @effect-diagnostics-next-line globalDate:off - The grace deadline is wall-clock time shared by web and native clients. + return Date.now(); + }; + const schedule = () => { + clearTimeout(timer); + timer = undefined; + let next = Infinity; + for (const state of states.values()) { + if (state.deadline !== null) next = Math.min(next, state.deadline); + } + if (next === Infinity) return; + timer = setTimeout( + () => { + const at = now(); + for (const [key, state] of states) { + if (state.deadline !== null && state.deadline <= at) { + state.working = false; + state.deadline = null; + returns.set(key, at); + } + } + schedule(); + version++; + for (const listener of listeners) listener(); + }, + Math.max(0, next - now()), + ); + }; return { - observe( - threads: ReadonlyArray | null, - ): (thread: Pick) => number | undefined { + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot: () => version, + observe(threads: readonly TrackedInboxThread[] | null): { + readonly isWorking: (thread: WorkingThreadInput & ThreadKeyInput) => boolean; + readonly returnedAt: (thread: ThreadKeyInput) => number | undefined; + } { if (threads === null) { - lastWorkingKeys = null; + clearTimeout(timer); + timer = undefined; + states.clear(); returns.clear(); - return () => undefined; + return { isWorking: isThreadWorking, returnedAt: () => undefined }; } - const working = new Set(); const present = new Set(); + const at = now(); + // @effect-diagnostics-next-line globalDate:off - Snooze checks compare wall-clock ISO times. + const observedAt = new Date(at).toISOString(); for (const thread of threads) { const key = keyOf(thread); present.add(key); - if (isThreadWorking(thread)) working.add(key); - } - for (const key of returns.keys()) { - if (!present.has(key)) returns.delete(key); + const placement = JSON.stringify([ + thread.archivedAt, + thread.deletedAt, + thread.pinnedAt, + thread.settledOverride, + thread.settledAt, + thread.snoozedAt, + thread.snoozedUntil, + thread.activeOrderKey, + ]); + const previous = states.get(key); + const actionable = + thread.hasPendingApprovals || + thread.hasPendingUserInput || + thread.pendingSubagentRequest != null || + (thread.pendingGuestMessageCount ?? 0) > 0 || + (thread.interactionMode === "plan" && thread.hasActionableProposedPlan) || + thread.runtime?.lastError != null || + thread.latestRun?.status === "failed"; + const moved = previous !== undefined && previous.placement !== placement; + const excluded = + thread.archivedAt != null || + thread.deletedAt != null || + thread.pinnedAt != null || + thread.settledOverride === "settled" || + effectiveSnoozed(thread, { now: observedAt }); + const actual = isThreadWorking(thread); + // Only shelf placement is held: execution state and attention requests stay authoritative. + const deadline = + !actual && previous?.working && !actionable && !moved && !excluded + ? (previous.deadline ?? at + WORKING_EXIT_GRACE_MS) + : null; + const working = actual || (deadline !== null && deadline > at); + if (previous?.working && !working) returns.set(key, at); + states.set(key, { working, deadline: working ? deadline : null, placement }); } - // @effect-diagnostics-next-line globalDate:off - const at = Date.now(); - for (const key of lastWorkingKeys ?? []) { - if (present.has(key) && !working.has(key)) returns.set(key, at); + for (const key of states.keys()) { + if (!present.has(key)) { + states.delete(key); + returns.delete(key); + } } - lastWorkingKeys = working; - const observed = new Map(returns); - return (thread) => observed.get(keyOf(thread)); + schedule(); + const observedWorking = new Map([...states].map(([key, state]) => [key, state.working])); + const observedReturns = new Map(returns); + return { + isWorking: (thread) => observedWorking.get(keyOf(thread)) ?? isThreadWorking(thread), + returnedAt: (thread) => observedReturns.get(keyOf(thread)), + }; }, - returnedAt: (thread: Pick) => - returns.get(keyOf(thread)), + isWorking: (thread: WorkingThreadInput & ThreadKeyInput) => + states.get(keyOf(thread))?.working ?? isThreadWorking(thread), + returnedAt: (thread: ThreadKeyInput) => returns.get(keyOf(thread)), }; } From 906d1e9fa1509effe4f6ac5144fe2669465fa416 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:03:18 -0700 Subject: [PATCH 002/222] fix(mobile): the iPhone compose icon matches the tab icons' size (#24650) Co-authored-by: Claude Opus 5.5 --- .../modules/t3-native-controls/ios/T3SidebarTabBarView.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift index b7afac0b4b987..f96c00962682c 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift @@ -92,8 +92,7 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { } configuration.cornerStyle = .capsule configuration.baseForegroundColor = .label - configuration.image = UIImage(systemName: "square.and.pencil") - configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 24) + configuration.image = tabIcon(UIImage(systemName: "square.and.pencil")) composeButton.configuration = configuration } From a43b0032cf3cee8e16e407c7056f180fb1652a8a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:06:32 -0700 Subject: [PATCH 003/222] feat(infra): share Vite task cache with Axiom savings telemetry (#24693) --- .github/actions/setup-ci-deps/action.yml | 22 + .github/workflows/ci.yml | 30 ++ .github/workflows/release.yml | 10 + .task-cache.json | 3 + apps/server/vite.config.ts | 4 +- infra/task-cache/README.md | 77 +++ infra/task-cache/alchemy.run.ts | 28 + infra/task-cache/install.sh | 3 + .../migrations/savings/0001_savings.sql | 16 + .../savings/0002_unspecified_tier.sql | 2 + .../savings/0003_remove_setup_probes.sql | 4 + infra/task-cache/package.json | 25 + infra/task-cache/scripts/install-ci.ts | 49 ++ infra/task-cache/scripts/install.ts | 307 +++++++++++ infra/task-cache/scripts/manage.ts | 72 +++ infra/task-cache/scripts/smoke.ts | 130 +++++ infra/task-cache/src/Admission.test.ts | 49 ++ infra/task-cache/src/Admission.ts | 28 + infra/task-cache/src/Cache.test.ts | 324 ++++++++++++ infra/task-cache/src/Cache.ts | 240 +++++++++ infra/task-cache/src/Savings.test.ts | 71 +++ infra/task-cache/src/Savings.ts | 140 +++++ infra/task-cache/src/Summary.ts | 64 +++ infra/task-cache/src/WriterRegistry.test.ts | 247 +++++++++ infra/task-cache/src/WriterRegistry.ts | 128 +++++ infra/task-cache/src/deploymentConfig.ts | 39 ++ infra/task-cache/src/developerAuth.test.ts | 37 ++ infra/task-cache/src/developerAuth.ts | 35 ++ infra/task-cache/src/flush.ts | 3 + infra/task-cache/src/nativeHook.js | 17 + infra/task-cache/src/nativeHook.test.ts | 86 +++ infra/task-cache/src/nativeRunHook.js | 65 +++ infra/task-cache/src/observability.ts | 203 +++++++ infra/task-cache/src/preload.mjs | 42 ++ infra/task-cache/src/protocol.ts | 21 + infra/task-cache/src/protocolTypes.ts | 14 + infra/task-cache/src/remoteCache.ts | 30 ++ infra/task-cache/src/resources.ts | 46 ++ infra/task-cache/src/runHook.test.ts | 496 ++++++++++++++++++ infra/task-cache/src/runHook.ts | 41 ++ infra/task-cache/src/telemetry.test.ts | 162 ++++++ infra/task-cache/src/telemetry.ts | 256 +++++++++ infra/task-cache/src/telemetryAuth.test.ts | 84 +++ infra/task-cache/src/telemetryAuth.ts | 44 ++ infra/task-cache/src/worker.ts | 98 ++++ infra/task-cache/tsconfig.json | 10 + infra/task-cache/types/upstream.d.ts | 12 + .../vite-plus-remote-cache-cloudflare/LICENSE | 21 + .../README.md | 1 + .../migrations/0001_cache.sql | 160 ++++++ .../migrations/0002_pull_requests.sql | 12 + .../package.json | 10 + .../src/admission.ts | 17 + .../src/auth.ts | 166 ++++++ .../src/cbor.ts | 151 ++++++ .../src/database.ts | 291 ++++++++++ .../src/errors.ts | 42 ++ .../src/fetch.ts | 42 ++ .../src/index.ts | 130 +++++ .../src/limits.ts | 42 ++ .../src/multipart.ts | 120 +++++ .../src/observations.ts | 42 ++ .../src/store.ts | 131 +++++ .../src/streams.ts | 136 +++++ knip.jsonc | 13 + pnpm-lock.yaml | 105 ++++ vite.config.ts | 15 + 67 files changed, 5560 insertions(+), 1 deletion(-) create mode 100644 .task-cache.json create mode 100644 infra/task-cache/README.md create mode 100644 infra/task-cache/alchemy.run.ts create mode 100644 infra/task-cache/install.sh create mode 100644 infra/task-cache/migrations/savings/0001_savings.sql create mode 100644 infra/task-cache/migrations/savings/0002_unspecified_tier.sql create mode 100644 infra/task-cache/migrations/savings/0003_remove_setup_probes.sql create mode 100644 infra/task-cache/package.json create mode 100644 infra/task-cache/scripts/install-ci.ts create mode 100644 infra/task-cache/scripts/install.ts create mode 100644 infra/task-cache/scripts/manage.ts create mode 100644 infra/task-cache/scripts/smoke.ts create mode 100644 infra/task-cache/src/Admission.test.ts create mode 100644 infra/task-cache/src/Admission.ts create mode 100644 infra/task-cache/src/Cache.test.ts create mode 100644 infra/task-cache/src/Cache.ts create mode 100644 infra/task-cache/src/Savings.test.ts create mode 100644 infra/task-cache/src/Savings.ts create mode 100644 infra/task-cache/src/Summary.ts create mode 100644 infra/task-cache/src/WriterRegistry.test.ts create mode 100644 infra/task-cache/src/WriterRegistry.ts create mode 100644 infra/task-cache/src/deploymentConfig.ts create mode 100644 infra/task-cache/src/developerAuth.test.ts create mode 100644 infra/task-cache/src/developerAuth.ts create mode 100644 infra/task-cache/src/flush.ts create mode 100644 infra/task-cache/src/nativeHook.js create mode 100644 infra/task-cache/src/nativeHook.test.ts create mode 100644 infra/task-cache/src/nativeRunHook.js create mode 100644 infra/task-cache/src/observability.ts create mode 100644 infra/task-cache/src/preload.mjs create mode 100644 infra/task-cache/src/protocol.ts create mode 100644 infra/task-cache/src/protocolTypes.ts create mode 100644 infra/task-cache/src/remoteCache.ts create mode 100644 infra/task-cache/src/resources.ts create mode 100644 infra/task-cache/src/runHook.test.ts create mode 100644 infra/task-cache/src/runHook.ts create mode 100644 infra/task-cache/src/telemetry.test.ts create mode 100644 infra/task-cache/src/telemetry.ts create mode 100644 infra/task-cache/src/telemetryAuth.test.ts create mode 100644 infra/task-cache/src/telemetryAuth.ts create mode 100644 infra/task-cache/src/worker.ts create mode 100644 infra/task-cache/tsconfig.json create mode 100644 infra/task-cache/types/upstream.d.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/LICENSE create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/README.md create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0001_cache.sql create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0002_pull_requests.sql create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/package.json create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/admission.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/auth.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/cbor.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/database.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/errors.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/fetch.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/index.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/limits.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/multipart.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/observations.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/store.ts create mode 100644 infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/streams.ts diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index 7a6a1aa122a70..e0ef427bd1d36 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -1,5 +1,10 @@ name: Setup CI dependencies description: Install Vite+ and the workspace dependencies from cache. +inputs: + task-cache-read-token: + description: Read-only credential for the private maintainer task cache + required: false + default: "" runs: using: composite steps: @@ -65,3 +70,20 @@ runs: - name: Install dependencies shell: bash run: vp install + + - name: Enable shared task cache + if: >- + github.repository == 'pingdotgg/t3code' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && + hashFiles('.task-cache.json') != '' && + inputs.task-cache-read-token != '' + shell: bash + env: + T3_TASK_CACHE_READ_TOKEN: ${{ inputs.task-cache-read-token }} + run: | + node infra/task-cache/scripts/install-ci.ts + if [[ ( "$GITHUB_EVENT_NAME" == pull_request && "$GITHUB_BASE_REF" == main ) || ( "$GITHUB_EVENT_NAME" == push && "$GITHUB_REF" == refs/heads/main ) ]]; then + echo 'VP_REMOTE_CACHE=read-write' >> "$GITHUB_ENV" + else + echo 'VP_REMOTE_CACHE=read' >> "$GITHUB_ENV" + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6707b0151bb3d..8023fafd61559 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: # file, docs included. The `Check` job at the end of this file passes only # when every other job passes or was skipped by `changes`. lint: + permissions: + contents: read + id-token: write name: Lint runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 @@ -82,6 +85,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -108,6 +113,9 @@ jobs: run: vp check build: + permissions: + contents: read + id-token: write name: Build and Typecheck needs: changes if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} @@ -139,6 +147,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -197,6 +207,9 @@ jobs: # dependency layers. The concurrency limit stays at the default 4 so peak load # per runner is unchanged. test: + permissions: + contents: read + id-token: write name: Test needs: changes if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} @@ -237,6 +250,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -278,6 +293,9 @@ jobs: # started last and ran alone for about 40s after everything else finished. # release.yml mirrors this test, test_web, and test_server split. test_web: + permissions: + contents: read + id-token: write name: Test Web needs: changes if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} @@ -295,6 +313,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # The browser project runs React tests in headless Chromium. Same cache key as # test_server, so both jobs share one downloaded browser. @@ -333,6 +353,9 @@ jobs: # The shards are the longest jobs, so they gate themselves instead of waiting # ~15s for `changes`: a shard the change cannot affect stops after checkout. test_server: + permissions: + contents: read + id-token: write name: Test Server ${{ matrix.shard }} runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 @@ -359,6 +382,8 @@ jobs: - name: Setup Vite+ if: steps.changes.outputs.test_server != 'false' uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` @@ -495,6 +520,9 @@ jobs: run: vp run lint:mobile release_smoke: + permissions: + contents: read + id-token: write name: Release Smoke needs: changes if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} @@ -512,6 +540,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a80220c28a87..d1045a239c876 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,6 +131,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata @@ -245,6 +247,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -275,6 +279,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -309,6 +315,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Resolve Playwright version id: playwright_version @@ -359,6 +367,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` diff --git a/.task-cache.json b/.task-cache.json new file mode 100644 index 0000000000000..0e62827f9d64e --- /dev/null +++ b/.task-cache.json @@ -0,0 +1,3 @@ +{ + "endpoint": "https://t3-task-cache.pinglabs.workers.dev/projects/t3code" +} diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 28ecce1a6986a..5d36d0867c597 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -62,9 +62,11 @@ const packExecutableTargets = packExecutableTarget // The root config's `projects` route repo-root runs to package configs, // including this one. Inheriting them would make this config a container. const { projects: _rootTestProjects, ...baseTestConfig } = baseConfig.test ?? {}; +// Workspace cache configuration is invalid on a package config. +const { run: _rootRunConfig, ...basePackageConfig } = baseConfig; export default mergeConfig( - { ...baseConfig, test: baseTestConfig }, + { ...basePackageConfig, test: baseTestConfig }, defineConfig({ run: { tasks: { diff --git a/infra/task-cache/README.md b/infra/task-cache/README.md new file mode 100644 index 0000000000000..55633992e9bb8 --- /dev/null +++ b/infra/task-cache/README.md @@ -0,0 +1,77 @@ +# Shared Vite Task cache + +[Alchemy](https://alchemy.run) manages the `T3TaskCache` stack: the `t3-task-cache` Worker, its D1 index, private R2 bucket, rate limits, and cleanup schedule. The production stack adopts the existing resources and preserves their data and repository authorization policy. D1 and R2 have a retain removal policy. + +The Worker uses Effect services for request handling, developer authentication, bounded concurrency, deadlines, and cleanup. The pinned [upstream protocol package](https://github.com/voidzero-dev/vite-plus-remote-cache-cloudflare/tree/5b06c2c10d84ea441735ba07c46f6d1b7cb2ff3b) supplies the experimental wire format, streaming upload, GitHub OIDC validation, and atomic D1 publication. Keeping that adapter preserves compatibility with existing cache entries. + +Vite+ 1.3 includes an experimental remote client even though its TypeScript configuration and CLI help omit it. The native client reads `run.cache.remote.url`, `VP_REMOTE_CACHE_URL`, and `VP_REMOTE_CACHE`. An opt-in Node preload patches the Vite+ JavaScript entrypoint in memory and invokes an optional repository hook before and after the native task runner. The global executable and PATH are unchanged. Vite config loads saved credentials into its in-memory remote URL, and the hook selects the native cache mode and exports task summaries. Saved credentials are never exported into task environments. Normal dependency installs do not patch Vite+. The hook is installed only by explicit maintainer setup or our own CI; without credentials it does not collect or queue telemetry. + +## Deploy + +Install dependencies with `vp install`, then authenticate with Alchemy's Cloudflare profile. Alternatively, provide `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` through your environment. Alchemy stores deployment state remotely with `Cloudflare.state()`. + +```sh +cd infra/task-cache +alchemy deploy --stage prod +``` + +The stack reads read-only credential digests from `~/.config/t3-task-cache/readers.json` (or `READER_KEYS`) and writer digests from `~/.config/t3-task-cache/writers.json` and supplies them as a redacted Worker secret. Preserve that file to administer existing writers. Deployment uses the `prod` stage and the existing endpoint in `.task-cache.json`. The upstream protocol is loaded only inside the Worker runtime and bundled by Alchemy, so deployment runs on Node without an extra TypeScript loader. An explicit `DEVELOPER_KEYS` environment variable overrides the local registry, including on deployment hosts without that file. Deployment fails when neither source exists, so a missing local file cannot silently revoke existing writers. + +The production database’s `scopes` and `deployment` records control repository identity, push branch, quotas, and retention. `pull_requests` explicitly enables same-repository PR uploads targeting the configured `branch`; `writes_enabled` disables all artifact uploads, including those from maintainers. Telemetry accepts reports from other PR bases without granting artifact writes. Provisioning a separate cache requires its own repository policy; a new empty database does not grant uploads. + +The CI read identity is named `ci-reader`. Its raw token belongs in the GitHub repository secret `T3_TASK_CACHE_READ_TOKEN`; `readers.json` maps that name to the token’s SHA-256 hex digest. To rotate it, replace that digest, deploy, then replace the GitHub secret. Keep the optional private `ci-reader.json` file as `{ "name": "ci-reader", "token": "…" }` for the smoke test to verify read-only restoration; without it, smoke uses the operator’s saved credential. Never commit these files. The read credential cannot upload artifacts or telemetry. Release jobs lack OIDC and their telemetry is not recorded. + +## Add a contributor or machine + +```sh +node infra/task-cache/scripts/manage.ts add-writer maria +``` + +This updates the local writer registry and writes `~/.config/t3-task-cache/maria.json`. Run `alchemy deploy --stage prod` from `infra/task-cache` to apply the registry, then transfer that file through a private channel. The recipient runs: + +```sh +bash infra/task-cache/install.sh +``` + +The installer prompts for the endpoint, developer name, and token (hidden), verifies authentication with an empty telemetry report, and saves credentials with mode `0600`. It installs the hook and credentials once in the home directory and enables the preload through `NODE_OPTIONS` in shell startup files. Open a fresh shell after setup. New worktrees and dependency reinstalls automatically use the same hook; no checkout or executable is modified. Existing long-running agents need to restart or inherit the updated environment. Rerunning setup updates the shared hook and restores matching backups from the old on-disk patch. + +Uninstall with `bash infra/task-cache/install.sh --uninstall`, then restart existing shells and agents. This removes shell integration, saved client credentials, and queued telemetry. A no-op preload remains for already-running shells that still inherit its path; no cache code runs through it. Administrative writer digests and issued credential files are preserved. + +Explicit environment settings take precedence: `VP_REMOTE_CACHE_URL` may contain the URL-encoded developer name and token as HTTP Basic credentials, and `VP_REMOTE_CACHE=read-write` enables uploads. These can be supplied by a secret manager instead of saved credentials. Do not put credentials in shell command history or commit them. Use separate writer names for separate people. Repeating `add-writer` rotates the token; `revoke-writer ` removes it. Deploy again to apply either change. + +Our repository’s CI explicitly installs the Vite+ hook. Same-repository PRs targeting `main` and default-branch push jobs read and write. PRs targeting other branches read only; fork PRs receive no cache credentials and run normally without remote caching. All artifact reads require authentication, including downloads by blob ID. Trusted maintainers automatically upload artifacts that other authenticated maintainers and CI consume. Deployment credentials are never needed on contributor machines or CI runners. + +## Verify + +```sh +node infra/task-cache/scripts/smoke.ts +vp run --filter @t3tools/task-cache test +vp run --filter @t3tools/task-cache typecheck +``` + +The smoke check uploads a unique fixture, restores its output into a cold second directory, changes its input, and confirms recomputation. It also checks that anonymous reads and uploads are rejected. Cacheable commands must run through `vp run`; direct `vp test` or `vp check` invocations do not use this cache. + +## Current limits + +- Vite Task separates operating systems and CPU architectures. macOS results do not satisfy Linux CI; Linux developer machines can populate compatible CI results. +- The current protocol retains one result for each execution key. Competing source versions can overwrite each other's entry. Inputs are validated before reuse, so this causes misses rather than stale hits. Merging to main does not promote or reset the cache. +- A local hit makes no remote request and is not uploaded retroactively. Clear only an isolated worktree's task cache when testing uploads of an already-cached task. +- Production retains results for seven days within an 8 GB budget. Remote errors fall back to execution. `VP_REMOTE_CACHE=off vp run ...` disables the remote tier for one invocation. + +## Savings and tracing + +Alchemy also owns the `t3-task-cache-traces` Axiom dataset, its ingest-only Worker token, a savings dashboard, and a separate retained D1 database for lifetime counters. Deployment needs an Axiom Alchemy profile or `AXIOM_TOKEN`/`AXIOM_ORG_ID` as well as Cloudflare credentials. Contributor machines never receive the Axiom token. + +Both `vp run` and its `vpr` shortcut invoke the hook. The hook observes Vite+'s atomic `last-summary.json` files, including nested runs. It reports package/task names, confirmed local and remote hits, avoided task runtime, developer identity, host, OS/architecture, and CI run ID. It does not send commands, arguments, source paths, environment variables, or task output. Developer identity comes from the authenticated cache writer; CI uses GitHub's verified OIDC actor. Same-repository PRs may upload artifacts as well as telemetry. Fork jobs do not install the hook or report telemetry. + +Reports spool locally and retry on subsequent commands. The completion hook queues the final summary, including failed runs. Local runs start a detached, short-lived Node process; CI awaits its bounded flush before the runner can terminate it. Collection or export failures never change the task exit status. No background service is installed. Queues retain at most 2,000 reports, each sent with a three-second timeout and a 15-second budget per flush. Stable report IDs and transactional D1 receipts prevent duplicate lifetime increments. Receipts expire after 31 days; accepted reports must be less than 30 days old. Lifetime totals survive raw trace expiration and are re-exported hourly, including inactive developers and hosts. Collection starts when the hook is installed; existing cache history is not backfilled. + +**Saved time means historical task execution time avoided.** It is useful as a compute proxy, but it is not measured CPU time, money saved, or elapsed developer waiting time. Parallel tasks can overlap, and cache lookup/restore overhead is not subtracted. Failed restores contribute no savings. Older Vite+ versions omit the hit source; their confirmed savings are counted under an explicit unspecified tier. Native summary collection is best-effort: interrupted runs or summaries overwritten before observation can be missing. The experimental Vite+ summary schema is tested here and must be checked when upgrading Vite+. + +Host labels use `~/.agents/MACHINE` when present, otherwise the hostname. GitHub-hosted runners use their runner OS/architecture class to avoid a new lifetime series for every ephemeral VM. Set `T3_TASK_CACHE_HOST` to override the label, or `T3_TASK_CACHE_TELEMETRY=off` to opt out while retaining remote caching. Re-run the installer to update an existing machine's hook. + +Writer administration requires the complete private registry on the administering machine. Copy that registry securely from its administrator before adding a writer on another machine. `add-writer --new-registry` explicitly initializes a new cache; never use it to recover an existing registry. + +Node processes running with filesystem permissions must allow the installed preload and hook directory. Remove the task-cache import from `NODE_OPTIONS` for a restricted process that cannot allow those files. + +Local telemetry is queued to disk before command exit and uploaded by a short-lived detached Node process. Slow or offline endpoints do not delay `vp run`; unsent reports retry on a later run. diff --git a/infra/task-cache/alchemy.run.ts b/infra/task-cache/alchemy.run.ts new file mode 100644 index 0000000000000..1de471ade0eda --- /dev/null +++ b/infra/task-cache/alchemy.run.ts @@ -0,0 +1,28 @@ +import { adopt } from "alchemy/AdoptPolicy"; +import * as Alchemy from "alchemy"; +import * as Axiom from "alchemy/Axiom"; +import * as Layer from "effect/Layer"; +import { Observability } from "./src/observability.ts"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Secrets from "alchemy/Secrets"; +import { WriterSecrets } from "./src/deploymentConfig.ts"; +import * as Worker from "./src/worker.ts"; + +export default Alchemy.Stack( + "T3TaskCache", + { + providers: Axiom.providers().pipe(Layer.provideMerge(Cloudflare.providers())), + state: Cloudflare.state(), + secrets: [Secrets.DotEnv(), Secrets.ProcessEnv(), WriterSecrets], + }, + Effect.gen(function* () { + const worker = yield* Worker.Worker; + const observability = yield* Observability; + return { + url: worker.url, + workerName: worker.workerName, + dashboard: observability.dashboard.uid, + }; + }).pipe(Effect.provide(Worker.layer), adopt(true)), +); diff --git a/infra/task-cache/install.sh b/infra/task-cache/install.sh new file mode 100644 index 0000000000000..eebce1917ad31 --- /dev/null +++ b/infra/task-cache/install.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec node "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/scripts/install.ts" "$@" diff --git a/infra/task-cache/migrations/savings/0001_savings.sql b/infra/task-cache/migrations/savings/0001_savings.sql new file mode 100644 index 0000000000000..48701f45542fc --- /dev/null +++ b/infra/task-cache/migrations/savings/0001_savings.sql @@ -0,0 +1,16 @@ +CREATE TABLE receipts (id TEXT PRIMARY KEY, recorded_at INTEGER NOT NULL) WITHOUT ROWID; +CREATE TABLE savings ( + developer TEXT NOT NULL, + host TEXT NOT NULL, + platform TEXT NOT NULL, + arch TEXT NOT NULL, + ci INTEGER NOT NULL, + local_ms INTEGER NOT NULL, + remote_ms INTEGER NOT NULL, + local_hits INTEGER NOT NULL, + remote_hits INTEGER NOT NULL, + executed INTEGER NOT NULL, + restore_failures INTEGER NOT NULL, + PRIMARY KEY (developer, host, platform, arch, ci) +) WITHOUT ROWID; +CREATE INDEX receipts_age ON receipts(recorded_at); diff --git a/infra/task-cache/migrations/savings/0002_unspecified_tier.sql b/infra/task-cache/migrations/savings/0002_unspecified_tier.sql new file mode 100644 index 0000000000000..9f3bf5e69143e --- /dev/null +++ b/infra/task-cache/migrations/savings/0002_unspecified_tier.sql @@ -0,0 +1,2 @@ +ALTER TABLE savings ADD COLUMN unspecified_ms INTEGER NOT NULL DEFAULT 0; +ALTER TABLE savings ADD COLUMN unspecified_hits INTEGER NOT NULL DEFAULT 0; diff --git a/infra/task-cache/migrations/savings/0003_remove_setup_probes.sql b/infra/task-cache/migrations/savings/0003_remove_setup_probes.sql new file mode 100644 index 0000000000000..2bd529ebbbb17 --- /dev/null +++ b/infra/task-cache/migrations/savings/0003_remove_setup_probes.sql @@ -0,0 +1,4 @@ +-- Empty installer authentication probes are not task execution history. +DELETE FROM savings +WHERE local_hits = 0 AND remote_hits = 0 AND unspecified_hits = 0 + AND executed = 0 AND restore_failures = 0; diff --git a/infra/task-cache/package.json b/infra/task-cache/package.json new file mode 100644 index 0000000000000..03c7776baa77d --- /dev/null +++ b/infra/task-cache/package.json @@ -0,0 +1,25 @@ +{ + "name": "@t3tools/task-cache", + "private": true, + "type": "module", + "scripts": { + "test": "vp test run --config ../../vite.config.ts --dir .", + "typecheck": "tsc --noEmit", + "smoke": "node scripts/smoke.ts", + "deploy": "alchemy deploy --stage prod", + "writers": "node scripts/manage.ts" + }, + "dependencies": { + "alchemy": "2.0.0-beta.80", + "effect": "catalog:", + "jose": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260601.1", + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/infra/task-cache/scripts/install-ci.ts b/infra/task-cache/scripts/install-ci.ts new file mode 100644 index 0000000000000..0561507de79ff --- /dev/null +++ b/infra/task-cache/scripts/install-ci.ts @@ -0,0 +1,49 @@ +// @effect-diagnostics nodeBuiltinImport:off - installs the hook before running project tasks. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +if (process.env.GITHUB_REPOSITORY !== "pingdotgg/t3code" || process.env.GITHUB_ACTIONS !== "true") + throw new Error("This setup is only for the maintainer repository's GitHub Actions."); +const root = NodePath.resolve(import.meta.dirname, "../../.."); +const hookConfig = NodePath.join(NodeOS.homedir(), ".config/vite-plus/run-hook.json"); +await NodeFSP.mkdir(NodePath.dirname(hookConfig), { recursive: true }); +await NodeFSP.writeFile( + hookConfig, + JSON.stringify({ + rootMarker: ".task-cache.json", + module: NodePath.join(root, "infra/task-cache/src/runHook.ts"), + }), +); +const { endpoint } = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, ".task-cache.json"), "utf8"), +); +const token = process.env.T3_TASK_CACHE_READ_TOKEN; +if (!token || token.length < 32) throw new Error("Missing task-cache read credential"); +const credentialFile = NodePath.join(NodeOS.homedir(), ".config/t3-task-cache/credentials.json"); +await NodeFSP.mkdir(NodePath.dirname(credentialFile), { recursive: true, mode: 0o700 }); +await NodeFSP.writeFile( + credentialFile, + JSON.stringify({ [endpoint]: { name: "ci-reader", token } }), + { mode: 0o600 }, +); +const githubEnv = process.env.GITHUB_ENV; +if (!githubEnv) throw new Error("Missing GitHub Actions environment file"); +const directory = process.env.RUNNER_TEMP; +if (!directory) throw new Error("Missing GitHub Actions temporary directory"); +const shellSetup = NodePath.join(directory, "t3-task-cache.sh"); +const quote = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; +const preload = NodeURL.pathToFileURL(NodePath.join(root, "infra/task-cache/src/preload.mjs")).href; +// GitHub prohibits NODE_OPTIONS in GITHUB_ENV; bash exports it to task subprocesses instead. +await NodeFSP.writeFile( + shellSetup, + [ + process.env.BASH_ENV && process.env.BASH_ENV !== shellSetup + ? `. ${quote(process.env.BASH_ENV)}` + : "", + `export NODE_OPTIONS="\${NODE_OPTIONS:+$NODE_OPTIONS }"${quote(`--import=${preload}`)}`, + "", + ].join("\n"), +); +await NodeFSP.appendFile(githubEnv, `BASH_ENV=${shellSetup}\n`); diff --git a/infra/task-cache/scripts/install.ts b/infra/task-cache/scripts/install.ts new file mode 100644 index 0000000000000..5826189de7648 --- /dev/null +++ b/infra/task-cache/scripts/install.ts @@ -0,0 +1,307 @@ +/* oxlint-disable t3code/no-global-process-runtime -- Standalone setup must run before workspace dependencies are installed. */ +// @effect-diagnostics nodeBuiltinImport:off - standalone bootstrap also runs before dependencies are installed. +// @effect-diagnostics globalConsole:off globalFetch:off globalDate:off - interactive setup and endpoint verification. +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeReadlinePromises from "node:readline/promises"; +import { patchNativeCli } from "../src/nativeHook.js"; + +const home = NodeOS.homedir(); +const root = NodePath.resolve(import.meta.dirname, "../../.."); +const state = NodePath.join(home, ".config/t3-task-cache"); +const nonInteractive = process.argv.includes("--non-interactive"); +const installed = NodePath.join(home, ".local/share/t3-task-cache"); +const hookConfig = NodePath.join(home, ".config/vite-plus/run-hook.json"); +const legacySourceLine = '. "$HOME/.config/t3-task-cache/env"'; +const sourceLine = + '[ ! -r "$HOME/.config/t3-task-cache/env" ] || . "$HOME/.config/t3-task-cache/env"'; +const profiles = [".profile", ".bashrc", ".bash_profile", ".bash_login", ".zshenv", ".zshrc"]; +if (process.argv.includes("--uninstall")) { + for (const file of profiles) { + const path = NodePath.join(home, file); + try { + const original = await NodeFSP.readFile(path, "utf8"); + const next = original + .split("\n") + .filter((line) => line !== sourceLine && line !== legacySourceLine) + .join("\n"); + if (next !== original) { + if (!next.trim() && (file === ".bash_profile" || file === ".bash_login")) + await NodeFSP.unlink(path); + else await NodeFSP.writeFile(path, next); + } + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } + } + try { + const config = JSON.parse(await NodeFSP.readFile(hookConfig, "utf8")); + if (config.module === NodePath.join(installed, "runHook.ts")) await NodeFSP.unlink(hookConfig); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } + await NodeFSP.rm(NodePath.join(state, "credentials.json"), { force: true }); + await NodeFSP.rm(NodePath.join(state, "env"), { force: true }); + await NodeFSP.rm(NodePath.join(home, ".cache/t3-task-cache/telemetry"), { + recursive: true, + force: true, + }); + // Existing shells still import this path until restarted; leave a harmless loader for them. + await NodeFSP.mkdir(installed, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(installed, "preload.mjs"), + "// Task cache uninstalled. Safe for shells opened before uninstall.\n", + ); + for (const file of await NodeFSP.readdir(installed)) { + if (file !== "preload.mjs") + await NodeFSP.rm(NodePath.join(installed, file), { recursive: true, force: true }); + } + console.log( + "Task cache uninstalled. Restart existing shells and agents to clear their inherited preload.", + ); + process.exit(0); +} + +const { endpoint: defaultEndpoint } = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, ".task-cache.json"), "utf8"), +); +let credentials: Record = {}; +try { + credentials = JSON.parse( + await NodeFSP.readFile(NodePath.join(state, "credentials.json"), "utf8"), + ); +} catch (error) { + if ( + !(error instanceof SyntaxError) && + !(error instanceof Error && "code" in error && error.code === "ENOENT") + ) + throw error; +} +const prompt = NodeReadlinePromises.createInterface({ + input: process.stdin, + output: process.stdout, +}); +let endpoint = process.env.T3_CACHE_SETUP_URL || defaultEndpoint; +let name = process.env.T3_CACHE_SETUP_NAME || credentials[endpoint]?.name; +let token = process.env.T3_CACHE_SETUP_TOKEN || credentials[endpoint]?.token; +const initialEndpoint = endpoint; +const initialName = name; +try { + if (!nonInteractive) { + endpoint = (await prompt.question(`Cache URL [${endpoint}]: `)).trim() || endpoint; + name = (await prompt.question(`Developer name${name ? ` [${name}]` : ""}: `)).trim() || name; + prompt.close(); + if (process.stdin.isTTY || !token || endpoint !== initialEndpoint || name !== initialName) { + const savedToken = endpoint === initialEndpoint && name === initialName ? token : undefined; + if (!process.stdin.isTTY) + throw new Error("Use T3_CACHE_SETUP_TOKEN for non-interactive setup."); + process.stdout.write( + `Credential token (hidden)${savedToken ? " [Enter keeps saved token]" : ""}: `, + ); + process.stdin.setRawMode(true); + process.stdin.resume(); + token = + (await new Promise((resolve, reject) => { + let value = ""; + const data = (chunk: Buffer) => { + for (const character of chunk.toString()) { + if (character === "\u0003") { + cleanup(); + reject(new Error("Cancelled")); + return; + } + if (character === "\r" || character === "\n") { + cleanup(); + resolve(value); + return; + } + if (character === "\u007f") value = value.slice(0, -1); + else value += character; + } + }; + const cleanup = () => { + process.stdin.off("data", data); + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write("\n"); + }; + process.stdin.on("data", data); + })) || savedToken; + } + } +} finally { + prompt.close(); +} +if (!name || !token || !/^[a-z0-9-]{1,64}$/.test(name) || token.length < 32) + throw new Error("A valid developer name and credential token are required."); +const url = new URL(endpoint); +if ( + (url.protocol !== "https:" && + !( + url.protocol === "http:" && + (url.hostname === "localhost" || + /^127(?:\.\d{1,3}){3}$/.test(url.hostname) || + url.hostname === "[::1]") + )) || + url.username || + url.password || + url.search || + url.hash +) + throw new Error( + "Use an HTTPS cache endpoint without embedded credentials, query, or fragment (HTTP is only allowed on loopback).", + ); +endpoint = url.href.replace(/\/$/, ""); +const response = await fetch(`${endpoint}/telemetry`, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${name}:${token}`).toString("base64")}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: NodeCrypto.randomBytes(32).toString("hex"), + recordedAt: Date.now(), + host: NodeOS.hostname(), + platform: NodeOS.platform(), + arch: NodeOS.arch(), + ci: false, + runId: "setup", + tasks: [], + }), + signal: AbortSignal.timeout(10_000), +}); +await response.body?.cancel(); +if (!response.ok) + throw new Error(`Credential verification failed (${response.status}); nothing was installed.`); + +const candidates = [NodePath.join(root, "node_modules/vite-plus/dist/bin.js")]; +for (const directory of [ + process.env.VITE_PLUS_HOME, + NodePath.join(home, ".vite-plus/current"), + NodePath.join(home, ".local/share/vite-plus/current"), +]) { + if (directory) candidates.push(NodePath.join(directory, "node_modules/vite-plus/dist/bin.js")); +} +for (let index = 0; index < process.argv.length; index++) { + if (process.argv[index] === "--entrypoint") { + const entrypoint = process.argv[++index]; + if (!entrypoint) throw new Error("Missing --entrypoint path"); + candidates.push(entrypoint); + } +} +const targets = new Map(); +for (const candidate of candidates) { + try { + const path = await NodeFSP.realpath(candidate); + const original = await NodeFSP.readFile(path, "utf8"); + const backup = await NodeFSP.readFile(`${path}.before-task-cache`, "utf8").catch( + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined; + throw error; + }, + ); + if (backup && (original === backup || original === patchNativeCli(backup))) + targets.set(path, { original: backup, patched: original }); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } +} + +await NodeFSP.mkdir(installed, { recursive: true }); +await NodeFSP.writeFile(NodePath.join(installed, "package.json"), '{"type":"module"}\n'); +for (const file of [ + "runHook.ts", + "remoteCache.ts", + "flush.ts", + "telemetry.ts", + "nativeHook.js", + "nativeRunHook.js", + "preload.mjs", +]) { + const destination = NodePath.join(installed, file); + const temporary = `${destination}.${NodeCrypto.randomUUID()}.tmp`; + try { + await NodeFSP.copyFile(NodePath.join(import.meta.dirname, "../src", file), temporary); + await NodeFSP.rename(temporary, destination); + } finally { + await NodeFSP.rm(temporary, { force: true }); + } +} +await NodeFSP.mkdir(NodePath.dirname(hookConfig), { recursive: true }); +await NodeFSP.writeFile( + hookConfig, + JSON.stringify({ + rootMarker: ".task-cache.json", + module: NodePath.join(installed, "runHook.ts"), + }), +); +for (const [path, { original }] of targets) { + await NodeFSP.writeFile(path, original); + await NodeFSP.rm(`${path}.before-task-cache`, { force: true }); + await NodeFSP.rm(NodePath.join(NodePath.dirname(path), "run-hook.js"), { force: true }); +} +for (const file of [ + "nativeHook.ts", + "launcher-telemetry.ts", + "launcherTelemetry.ts", + "vp.ts", + "bin", +]) + await NodeFSP.rm(NodePath.join(installed, file), { recursive: true, force: true }); +await NodeFSP.mkdir(state, { recursive: true, mode: 0o700 }); +credentials[endpoint] = { name, token }; +const temporaryCredentials = NodePath.join(state, `.credentials-${NodeCrypto.randomUUID()}.tmp`); +try { + await NodeFSP.writeFile(temporaryCredentials, JSON.stringify(credentials), { mode: 0o600 }); + await NodeFSP.rename(temporaryCredentials, NodePath.join(state, "credentials.json")); +} finally { + await NodeFSP.rm(temporaryCredentials, { force: true }); +} + +const environmentFile = NodePath.join(state, "env"); +const preload = NodeURL.pathToFileURL(NodePath.join(installed, "preload.mjs")).href; +await NodeFSP.writeFile( + environmentFile, + [ + `if [ -r "${NodePath.join(installed, "preload.mjs")}" ]; then`, + `case " \${NODE_OPTIONS-} " in`, + ` *" --import=${preload} "*) ;;`, + ` *) export NODE_OPTIONS="\${NODE_OPTIONS:+$NODE_OPTIONS }--import=${preload}" ;;`, + `esac`, + `fi`, + "", + ].join("\n"), + { mode: 0o600 }, +); +for (const file of profiles) { + const path = NodePath.join(home, file); + let original = ""; + try { + original = await NodeFSP.readFile(path, "utf8"); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } + if (file === ".bash_profile" || file === ".bash_login") { + if (!original) continue; + // Earlier installers created a profile that masks the user’s existing login profile. + if (file === ".bash_profile" && [sourceLine, legacySourceLine].includes(original.trim())) { + await NodeFSP.unlink(path); + continue; + } + } + if (original.split("\n").includes(legacySourceLine)) { + original = original + .split("\n") + .filter((line) => line !== legacySourceLine) + .join("\n"); + await NodeFSP.writeFile(path, original); + } + if (!original.split("\n").includes(sourceLine)) + await NodeFSP.appendFile(path, `\n${sourceLine}\n`); +} +console.log( + "Verified credentials and installed the machine-wide Vite+ hook. Open a fresh shell to enable it in all worktrees. Executables and PATH are unchanged.", +); diff --git a/infra/task-cache/scripts/manage.ts b/infra/task-cache/scripts/manage.ts new file mode 100644 index 0000000000000..212d633cadd1e --- /dev/null +++ b/infra/task-cache/scripts/manage.ts @@ -0,0 +1,72 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as Schema from "effect/Schema"; +import * as Console from "effect/Console"; +import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; +import * as Flag from "effect/cli/Flag"; +import * as Argument from "effect/cli/Argument"; +import * as Command from "effect/cli/Command"; + +import * as WriterRegistry from "../src/WriterRegistry.ts"; + +const decodeDeployment = Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ url: Schema.String })), +); + +const name = Argument.String("name").pipe(Argument.withDescription("Lowercase writer name")); +const updateWriter = Effect.fn("TaskCache.manageWriter")(function* ( + name: string, + revoke: boolean, + initialize = false, +) { + const registry = yield* WriterRegistry.WriterRegistry; + let endpoint: string | undefined; + if (!revoke && WriterRegistry.isWriterName(name)) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = yield* spawner.string( + ChildProcess.make( + "node", + [ + `${import.meta.dirname}/../node_modules/alchemy/bin/cli.js`, + "state", + "read", + "T3TaskCache/prod/output", + "--backend", + "cloudflare", + "--no-input", + ], + { cwd: `${import.meta.dirname}/..`, stderr: "inherit" }, + ), + ); + const deployed = yield* decodeDeployment(output); + endpoint = new URL("/projects/t3code", deployed.url).href; + } + const file = yield* registry.update(name, revoke, endpoint, initialize); + yield* Console.log( + file === undefined + ? `Removed ${name} from the local writer registry.` + : `Private credential written to ${file}.`, + ); + yield* Console.log( + "Run alchemy deploy --stage prod from infra/task-cache to apply the writer changes.", + ); +}); + +const command = Command.make("task-cache").pipe( + Command.withSubcommands([ + Command.make( + "add-writer", + { name, newRegistry: Flag.Boolean("new-registry").pipe(Flag.withDefault(false)) }, + ({ name, newRegistry }) => updateWriter(name, false, newRegistry), + ), + Command.make("revoke-writer", { name }, ({ name }) => updateWriter(name, true)), + ]), +); + +Command.run(command, { version: "1.0.0" }).pipe( + Effect.provide(WriterRegistry.layer.pipe(Layer.provideMerge(NodeServices.layer))), + NodeRuntime.runMain, +); diff --git a/infra/task-cache/scripts/smoke.ts b/infra/task-cache/scripts/smoke.ts new file mode 100644 index 0000000000000..79320ad105465 --- /dev/null +++ b/infra/task-cache/scripts/smoke.ts @@ -0,0 +1,130 @@ +// @effect-diagnostics nodeBuiltinImport:off - exercises real child processes and isolated workspaces. +// @effect-diagnostics globalConsole:off - emits the deployment acceptance transcript. +// @effect-diagnostics globalFetch:off - probes the deployed Worker directly. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +const project = NodePath.join(import.meta.dirname, "../../.."); +const env = { + ...process.env, + T3_TASK_CACHE_TELEMETRY: "off", + VP_REMOTE_CACHE: "read-write", + VP_REMOTE_CACHE_URL: undefined, +}; +const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-cache-probe-")); +const name = `t3-cache-probe-${NodeCrypto.randomUUID()}`; + +async function run(directory: string, home = NodeOS.homedir()) { + return new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn( + NodePath.join(project, "node_modules/.bin/vp"), + ["run", "--cache", "build"], + { + cwd: NodePath.join(root, directory), + env: { + ...env, + HOME: home, + PATH: `${NodePath.join(project, "node_modules/.bin")}${NodePath.delimiter}${process.env.PATH}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.on("error", reject); + child.on("close", (code) => (code === 0 ? resolve(output) : reject(new Error(output)))); + }); +} + +try { + const { endpoint } = JSON.parse( + await NodeFSP.readFile(NodePath.join(project, ".task-cache.json"), "utf8"), + ); + for (const [path, method] of [ + ["fetch", "POST"], + ["blob/12345678-1234-1234-1234-123456789abc", "GET"], + ] as const) { + const response = await fetch(`${endpoint}/${path}`, { method }); + await response.body?.cancel(); + if (response.status !== 401) + throw new Error(`Anonymous ${path} returned ${response.status}, expected 401.`); + } + const unauthorized = await fetch(`${endpoint}/store`, { method: "POST" }); + await unauthorized.body?.cancel(); + if (unauthorized.status !== 401) + throw new Error(`Anonymous upload returned ${unauthorized.status}, expected 401.`); + for (const directory of ["a", "b"]) { + const cwd = NodePath.join(root, directory); + await NodeFSP.mkdir(cwd); + await NodeFSP.writeFile(NodePath.join(cwd, ".task-cache.json"), JSON.stringify({ endpoint })); + await NodeFSP.writeFile( + NodePath.join(cwd, "vite.config.ts"), + `import { remoteCacheUrl } from ${JSON.stringify(NodePath.join(project, "infra/task-cache/src/remoteCache.ts"))}; export default {run:{cache:{scripts:true,remote:{url:remoteCacheUrl(import.meta.dirname)}}}}`, + ); + await NodeFSP.writeFile( + NodePath.join(cwd, "package.json"), + JSON.stringify({ + name, + private: true, + scripts: { build: `node build.cjs ${name}` }, + }), + ); + await NodeFSP.writeFile( + NodePath.join(cwd, "build.cjs"), + 'if(process.env.VP_REMOTE_CACHE_URL) throw new Error("Cache URL leaked into task environment"); const fs=require("node:fs"); fs.writeFileSync("output.txt",fs.readFileSync("input.txt")); console.log("computed");', + ); + await NodeFSP.writeFile(NodePath.join(cwd, "input.txt"), "version-a"); + } + console.log("First checkout:\n" + (await run("a"))); + const readerFile = NodePath.join(NodeOS.homedir(), ".config/t3-task-cache/ci-reader.json"); + let reader; + try { + reader = JSON.parse(await NodeFSP.readFile(readerFile, "utf8")); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error; + const credentials = JSON.parse( + await NodeFSP.readFile( + NodePath.join(NodeOS.homedir(), ".config/t3-task-cache/credentials.json"), + "utf8", + ), + ); + reader = credentials[endpoint]; + } + const otherHome = NodePath.join(root, "other-developer"); + await NodeFSP.mkdir(NodePath.join(otherHome, ".config/t3-task-cache"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(otherHome, ".config/t3-task-cache/credentials.json"), + JSON.stringify({ + [endpoint]: reader, + }), + ); + const second = await run("b", otherHome); + console.log("Cold second checkout:\n" + second); + if (!second.includes("remote cache hit")) + throw new Error("Second checkout did not reuse the remote result."); + if ((await NodeFSP.readFile(NodePath.join(root, "b/output.txt"), "utf8")) !== "version-a") + throw new Error("Output was not restored."); + await NodeFSP.writeFile(NodePath.join(root, "b/input.txt"), "version-b"); + const changed = await run("b"); + console.log("Changed input:\n" + changed); + if ( + changed.includes("cache hit") || + (await NodeFSP.readFile(NodePath.join(root, "b/output.txt"), "utf8")) !== "version-b" + ) + throw new Error("Changed input reused stale output."); + await NodeFSP.rm(NodePath.join(root, "a/node_modules"), { recursive: true, force: true }); + console.log("Original input after competing writer:\n" + (await run("a"))); + console.log( + "PASS: authenticated upload, cold-checkout restoration, input validation, and anonymous-write rejection.", + ); +} finally { + await NodeFSP.rm(root, { recursive: true, force: true }); +} diff --git a/infra/task-cache/src/Admission.test.ts b/infra/task-cache/src/Admission.test.ts new file mode 100644 index 0000000000000..c1fef5204796c --- /dev/null +++ b/infra/task-cache/src/Admission.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Admission from "./Admission.ts"; + +it.effect("queues uploads and releases the permit when an upload is interrupted", () => + Effect.gen(function* () { + const admission = yield* Admission.Admission; + const entered = yield* Deferred.make(); + const secondEntered = yield* Deferred.make(); + const first = yield* admission + .withPermit(true, Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never))) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + const second = yield* admission + .withPermit( + true, + Deferred.succeed(secondEntered, undefined).pipe(Effect.andThen(Effect.never)), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(secondEntered); + const third = yield* admission + .withPermit(true, Effect.succeed("restored")) + .pipe(Effect.forkChild); + yield* Fiber.interrupt(first); + expect(yield* Fiber.join(third)).toBe("restored"); + yield* Fiber.interrupt(second); + }).pipe(Effect.provide(Admission.layer)), +); + +it.effect("reads continue while all upload slots are occupied", () => + Effect.gen(function* () { + const admission = yield* Admission.Admission; + const entered = yield* Deferred.make(); + const occupied = yield* admission + .withPermit( + true, + admission.withPermit( + true, + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + expect(yield* admission.withPermit(false, Effect.succeed("hit"))).toBe("hit"); + yield* Fiber.interrupt(occupied); + }).pipe(Effect.provide(Admission.layer)), +); diff --git a/infra/task-cache/src/Admission.ts b/infra/task-cache/src/Admission.ts new file mode 100644 index 0000000000000..4f69041e8b491 --- /dev/null +++ b/infra/task-cache/src/Admission.ts @@ -0,0 +1,28 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; + +export class Admission extends Context.Service< + Admission, + { + readonly withPermit: ( + store: boolean, + task: Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/task-cache/Admission") {} + +const make = Effect.gen(function* () { + // Bound simultaneous upload buffers; Effect cancels queued waiters and releases permits. + const stores = yield* Semaphore.make(2); + const reads = yield* Semaphore.make(4); + return Admission.of({ + // The caller owns the request span; admission adds no second span per request. + withPermit: Effect.fnUntraced(function* (store, task) { + return yield* (store ? stores : reads).withPermits(1)(task); + }), + }); +}); + +export const layer = Layer.effect(Admission, make); diff --git a/infra/task-cache/src/Cache.test.ts b/infra/task-cache/src/Cache.test.ts new file mode 100644 index 0000000000000..7ff2a8fb76878 --- /dev/null +++ b/infra/task-cache/src/Cache.test.ts @@ -0,0 +1,324 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import { Deadline } from "../vendor/vite-plus-remote-cache-cloudflare/src/streams.ts"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; +import { Observations } from "../vendor/vite-plus-remote-cache-cloudflare/src/observations.ts"; +import * as Admission from "./Admission.ts"; +import * as Cache from "./Cache.ts"; +import * as Savings from "./Savings.ts"; + +vi.mock("../vendor/vite-plus-remote-cache-cloudflare/src/database.ts", () => ({ + getScope: async () => ({}), + selectBlob: async () => ({ blob_object: "artifact", blob_size: 4 }), + cleanup: async () => {}, +})); + +it.effect("includes successful blob downloads in exported R2 usage", () => + Effect.gen(function* () { + const get = vi.fn(async () => ({ size: 4, body: new Blob(["data"]).stream() })); + const bindings = { + developerKeys: JSON.stringify({ + julius: "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", + }), + ARTIFACTS: { get }, + readLimit: { limit: async () => ({ success: true }) }, + } as unknown as Cache.Bindings["Service"]; + using observations = vi.spyOn(Observations.prototype, "toJSON"); + const response = yield* Effect.gen(function* () { + const cache = yield* Cache.Cache; + return yield* cache.handle( + new Request( + "https://cache.example/projects/t3code/blob/12345678-1234-1234-1234-123456789abc", + { + headers: { + authorization: `Basic ${btoa("julius:fixture-token-012345678901234567890123456789")}`, + }, + }, + ), + { waitUntil: () => {} }, + ); + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, bindings), + Admission.layer, + Layer.succeed(Savings.Savings, { + record: () => Effect.void, + emitTotals: () => Effect.void, + cleanup: Effect.void, + }), + ]), + ), + ), + ); + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.text())).toBe("data"); + expect(get).toHaveBeenCalledWith("artifact"); + expect(observations.mock.results.at(-1)?.value.r2_operations).toBe(1); + }), +); + +it.effect("aborts the upstream deadline before disposing it on interruption", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const cancelled: boolean[] = []; + const originalDispose = Deadline.prototype.dispose; + using _dispose = vi.spyOn(Deadline.prototype, "dispose").mockImplementation(function ( + this: Deadline, + ) { + try { + this.check(); + cancelled.push(false); + } catch { + cancelled.push(true); + } + originalDispose.call(this); + }); + const bindings = { + developerKeys: JSON.stringify({ + julius: "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", + }), + ARTIFACTS: { + get: () => { + Deferred.doneUnsafe(entered, Effect.void); + return new Promise(() => {}); + }, + }, + readLimit: { limit: async () => ({ success: true }) }, + } as unknown as Cache.Bindings["Service"]; + yield* Effect.gen(function* () { + const cache = yield* Cache.Cache; + const fiber = yield* cache + .handle( + new Request( + "https://cache.example/projects/t3code/blob/12345678-1234-1234-1234-123456789abc", + { + headers: { + authorization: `Basic ${btoa("julius:fixture-token-012345678901234567890123456789")}`, + }, + }, + ), + { waitUntil: () => {} }, + ) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* Fiber.interrupt(fiber); + expect(cancelled).toEqual([true]); + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, bindings), + Admission.layer, + Layer.succeed(Savings.Savings, { + record: () => Effect.void, + emitTotals: () => Effect.void, + cleanup: Effect.void, + }), + ]), + ), + ), + ); + }), +); + +it.effect("does not charge authenticated budgets for anonymous or malformed requests", () => { + const limit = vi.fn(async (_options: { key: string }) => ({ success: true })); + return Effect.gen(function* () { + const cache = yield* Cache.Cache; + for (const operation of [ + "fetch", + "store", + "telemetry", + "blob/12345678-1234-1234-1234-123456789abc", + ]) { + for (const method of ["GET", "POST"]) { + yield* cache.handle( + new Request(`https://cache.example/projects/t3code/${operation}`, { method }), + { waitUntil: () => {} }, + ); + } + } + expect(limit).not.toHaveBeenCalled(); + const headers = { + authorization: `Basic ${btoa("julius:fixture-token-012345678901234567890123456789")}`, + }; + yield* cache.handle( + new Request("https://cache.example/projects/t3code/telemetry", { + method: "POST", + headers, + body: "{}", + }), + { waitUntil: () => {} }, + ); + yield* cache.handle( + new Request( + "https://cache.example/projects/t3code/blob/12345678-1234-1234-1234-123456789abc", + { headers }, + ), + { waitUntil: () => {} }, + ); + expect(limit.mock.calls).toEqual([[{ key: "t3code:telemetry" }], [{ key: "t3code:blob" }]]); + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, { + developerKeys: JSON.stringify({ + julius: "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", + }), + readerKeys: "{}", + readLimit: { limit }, + storeLimit: { limit }, + ARTIFACTS: { get: async () => ({ size: 4, body: new Blob(["data"]).stream() }) }, + } as unknown as Cache.Bindings["Service"]), + Admission.layer, + Layer.succeed(Savings.Savings, { + record: () => Effect.void, + emitTotals: () => Effect.void, + cleanup: Effect.void, + }), + ]), + ), + ), + ); +}); + +it.effect("authenticates setup probes without recording empty savings", () => { + const record = vi.fn(() => Effect.void); + const emitTotals = vi.fn(() => Effect.void); + return Effect.gen(function* () { + const cache = yield* Cache.Cache; + const response = yield* cache.handle( + new Request("https://cache.example/projects/t3code/telemetry", { + method: "POST", + headers: { + authorization: `Basic ${btoa("julius:fixture-token-012345678901234567890123456789")}`, + }, + body: JSON.stringify({ + id: "a".repeat(64), + recordedAt: 0, + host: "setup-host", + platform: "linux", + arch: "x64", + ci: false, + runId: "setup", + tasks: [], + }), + }), + { waitUntil: () => {} }, + ); + expect(response.status).toBe(204); + expect(record).not.toHaveBeenCalled(); + expect(emitTotals).not.toHaveBeenCalled(); + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, { + developerKeys: JSON.stringify({ + julius: "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", + }), + readLimit: { limit: async () => ({ success: true }) }, + } as unknown as Cache.Bindings["Service"]), + Admission.layer, + Layer.succeed(Savings.Savings, { record, emitTotals, cleanup: Effect.void }), + ]), + ), + ), + ); +}); + +it.effect("keeps artifacts private and limits CI reader credentials to reads", () => + Effect.gen(function* () { + const cache = yield* Cache.Cache; + const context = { waitUntil: () => {} }; + const blob = "https://cache.example/projects/t3code/blob/12345678-1234-1234-1234-123456789abc"; + for (const url of [blob, "https://cache.example/projects/t3code/fetch"]) { + const method = url === blob ? "GET" : "POST"; + expect((yield* cache.handle(new Request(url, { method }), context)).status).toBe(401); + expect( + (yield* cache.handle( + new Request(url, { + method, + headers: { authorization: `Basic ${btoa("ci-reader:wrong")}` }, + }), + context, + )).status, + ).toBe(403); + } + const headers = { + authorization: `Basic ${btoa("ci-reader:fixture-token-012345678901234567890123456789")}`, + }; + expect((yield* cache.handle(new Request(blob, { headers }), context)).status).toBe(200); + expect( + (yield* cache.handle( + new Request("https://cache.example/projects/t3code/store", { method: "POST", headers }), + context, + )).status, + ).toBe(403); + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, { + developerKeys: "{}", + readerKeys: JSON.stringify({ + "ci-reader": "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", + }), + ARTIFACTS: { get: vi.fn(async () => ({ size: 4, body: new Blob(["data"]).stream() })) }, + readLimit: { limit: async () => ({ success: true }) }, + storeLimit: { limit: async () => ({ success: true }) }, + } as unknown as Cache.Bindings["Service"]), + Admission.layer, + Layer.succeed(Savings.Savings, { + record: () => Effect.void, + emitTotals: () => Effect.void, + cleanup: Effect.void, + }), + ]), + ), + ), + ), +); + +it.effect("rejects anonymous and invalid Basic requests before admission", () => + Effect.gen(function* () { + const cache = yield* Cache.Cache; + for (const operation of ["store", "telemetry", "fetch"]) { + for (const authorization of [undefined, `Basic ${btoa("attacker:invalid")}`]) { + const response = yield* cache.handle( + new Request(`https://cache.example/projects/t3code/${operation}`, { + method: "POST", + headers: authorization ? { authorization } : {}, + }), + { waitUntil: () => {} }, + ); + expect(response.status).toBe(authorization ? 403 : 401); + } + } + }).pipe( + Effect.provide( + Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Cache.Bindings, { + developerKeys: "{}", + readerKeys: "{}", + } as Cache.Bindings["Service"]), + Layer.succeed(Admission.Admission, { + withPermit: () => Effect.die("Unauthenticated request reached admission"), + }), + Layer.succeed(Savings.Savings, { + record: () => Effect.void, + emitTotals: () => Effect.void, + cleanup: Effect.void, + }), + ]), + ), + ), + ), +); diff --git a/infra/task-cache/src/Cache.ts b/infra/task-cache/src/Cache.ts new file mode 100644 index 0000000000000..ce9759b498a26 --- /dev/null +++ b/infra/task-cache/src/Cache.ts @@ -0,0 +1,240 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as Cloudflare from "@cloudflare/workers-types"; +import { Summary, recordSummary } from "./Summary.ts"; +import * as Savings from "./Savings.ts"; +import type { ProtocolBindings } from "./protocolTypes.ts"; +import * as Admission from "./Admission.ts"; +import { developerIdentity } from "./developerAuth.ts"; + +const decodeSummary = Schema.decodeEffect(Schema.fromJsonString(Summary)); + +export class CacheError extends Schema.TaggedError()("CacheError", { + status: Schema.Number, + reason: Schema.String, +}) {} + +export class Bindings extends Context.Service< + Bindings, + ProtocolBindings & { + readonly developerKeys: string; + readonly readerKeys: string; + readonly readLimit: Cloudflare.RateLimit; + readonly storeLimit: Cloudflare.RateLimit; + } +>()("@t3tools/task-cache/Cache/Bindings") {} + +export class Cache extends Context.Service< + Cache, + { + readonly handle: ( + request: Request, + context: Pick, + ) => Effect.Effect; + readonly cleanup: Effect.Effect; + } +>()("@t3tools/task-cache/Cache") {} + +const make = Effect.gen(function* () { + const bindings = yield* Bindings; + const admission = yield* Admission.Admission; + const savings = yield* Savings.Savings; + // The source-only protocol dependency is bundled for Workers, never evaluated by Node during deployment. + const { + authorizeCacheWrite, + authorizeTelemetry, + keys, + cborResponse, + cleanup, + getScope, + selectBlob, + errorResponse, + HttpError, + fetchMetadata, + defaults, + store, + readBody, + Deadline, + Observations, + } = yield* Effect.promise(() => import("./protocol.ts")); + + // The upstream adapter owns the experimental wire format and atomic D1 publication. + const protocol = (run: (signal: AbortSignal) => Promise) => + Effect.tryPromise({ + try: run, + catch: (cause) => + new CacheError({ + status: cause instanceof HttpError ? cause.status : 503, + reason: cause instanceof HttpError ? cause.code : "unavailable", + }), + }); + + const checkLimit = Effect.fn("TaskCache.checkLimit")(function* (operation: string) { + const allowed = yield* protocol(() => + (operation === "store" ? bindings.storeLimit : bindings.readLimit).limit({ + key: `t3code:${operation.startsWith("blob/") ? "blob" : operation}`, + }), + ); + if (!allowed.success) return yield* new CacheError({ status: 429, reason: "rate_limit" }); + }); + + const handle = Effect.fn("TaskCache.handle")( + function* (request: Request, context: Pick) { + const url = new URL(request.url); + const route = /^\/projects\/t3code\/(fetch|store|telemetry|blob\/([0-9a-f-]{36}))$/.exec( + url.pathname, + ); + const operation = route?.[1] ?? "unknown"; + const writing = operation === "store"; + yield* Effect.annotateCurrentSpan({ + "cache.operation": operation.startsWith("blob/") ? "blob" : operation, + }); + if ( + !route || + url.search || + request.method !== (operation.startsWith("blob/") ? "GET" : "POST") + ) { + return yield* new CacheError({ status: 404, reason: "unknown_route" }); + } + if (!request.headers.has("authorization")) + return yield* new CacheError({ status: 401, reason: "missing_identity" }); + const developer = yield* developerIdentity(request, bindings.developerKeys); + if (writing || operation === "telemetry") { + if (developer === null) + return yield* new CacheError({ status: 403, reason: "invalid_identity" }); + } else { + const reader = developer ?? (yield* developerIdentity(request, bindings.readerKeys)); + if (typeof reader !== "string") + return yield* new CacheError({ status: 403, reason: "read_denied" }); + } + return yield* admission + .withPermit( + writing, + Effect.scoped( + Effect.gen(function* () { + const controller = yield* Effect.acquireRelease( + Effect.sync(() => new AbortController()), + (value) => Effect.sync(() => value.abort()), + ); + const deadline = yield* Effect.acquireRelease( + Effect.sync( + () => + new Deadline( + writing ? defaults.deadlineMs : 15000, + AbortSignal.any([request.signal, controller.signal]), + ), + ), + (value) => + Effect.sync(() => { + controller.abort(); + value.dispose(); + }), + ); + const stats = new Observations(); + yield* Effect.addFinalizer(() => + Effect.annotateCurrentSpan( + Object.fromEntries( + Object.entries(stats.toJSON()).map(([key, value]) => [`cache.${key}`, value]), + ), + ), + ); + if (!writing && operation !== "telemetry") yield* checkLimit(operation); + const scope = yield* protocol(() => getScope(bindings.INDEX, "t3code", stats)); + if (operation === "telemetry") { + let identity = developer ?? undefined; + if (identity === undefined) { + identity = yield* protocol(() => authorizeTelemetry(request, scope, keys)); + } + yield* checkLimit(operation); + const bytes = yield* protocol(() => readBody(request.body, 256 * 1024, deadline)); + const summary = yield* decodeSummary(new TextDecoder().decode(bytes)).pipe( + Effect.mapError(() => new CacheError({ status: 400, reason: "invalid_summary" })), + ); + if (summary.tasks.length === 0) return new Response(null, { status: 204 }); + const now = yield* Clock.currentTimeMillis; + if (summary.recordedAt > now + 60_000 || summary.recordedAt < now - 30 * 86400_000) + return yield* new CacheError({ status: 400, reason: "summary_expired" }); + yield* savings + .record(summary, identity) + .pipe( + Effect.mapError( + () => new CacheError({ status: 503, reason: "telemetry_unavailable" }), + ), + ); + yield* recordSummary(summary, identity); + yield* savings + .emitTotals(identity, summary.host) + .pipe( + Effect.mapError( + () => new CacheError({ status: 503, reason: "telemetry_unavailable" }), + ), + ); + return new Response(null, { status: 204 }); + } + if (writing) { + if (developer !== undefined && !scope.writes_enabled) { + return yield* new CacheError({ status: 403, reason: "developer_write_denied" }); + } + const now = yield* Clock.currentTimeMillis; + const identity = + developer === undefined + ? yield* protocol(() => authorizeCacheWrite(request, scope, keys)) + : { + exp: Math.floor(now / 1000) + 900, + repository_id: scope.repository_id, + workflow_ref: `developer/${developer}`, + }; + yield* checkLimit(operation); + return yield* protocol(() => + store(request, bindings, context, scope, identity, defaults, deadline, stats), + ); + } + if (operation === "fetch") { + const result = yield* protocol(() => + fetchMetadata(request, bindings, "t3code", defaults, deadline, stats), + ); + return cborResponse(result); + } + const selected = yield* protocol(() => + selectBlob(bindings.INDEX, "t3code", route[2]!, stats), + ); + if (!selected) return yield* new CacheError({ status: 404, reason: "blob_missing" }); + stats.r2_operations++; + const object = yield* protocol(() => bindings.ARTIFACTS.get(selected.blob_object)); + if (!object) return yield* new CacheError({ status: 404, reason: "blob_missing" }); + if (object.size !== selected.blob_size) + return yield* new CacheError({ status: 503, reason: "blob_size" }); + return new Response(object.body, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(object.size), + "Cache-Control": "no-store", + }, + }); + }), + ), + ) + .pipe( + Effect.timeout(writing ? "2 minutes" : "15 seconds"), + Effect.catchTags({ + TimeoutError: () => Effect.fail(new CacheError({ status: 503, reason: "deadline" })), + }), + ); + }, + Effect.catchTags({ + CacheError: (error) => + Effect.gen(function* () { + if (error.status >= 500) + yield* Effect.logWarning("Cache request failed", { reason: error.reason }); + return errorResponse(new HttpError(error.status, error.reason)); + }), + }), + ); + + return Cache.of({ handle, cleanup: protocol(() => cleanup(bindings)) }); +}); + +export const layer = Layer.effect(Cache, make); diff --git a/infra/task-cache/src/Savings.test.ts b/infra/task-cache/src/Savings.test.ts new file mode 100644 index 0000000000000..d61e685150e16 --- /dev/null +++ b/infra/task-cache/src/Savings.test.ts @@ -0,0 +1,71 @@ +// @effect-diagnostics nodeBuiltinImport:off - real SQLite verifies the D1 transaction's deduplication. +import * as NodeSqlite from "node:sqlite"; +import * as NodeFS from "node:fs"; +import * as Effect from "effect/Effect"; +import type * as Cloudflare from "@cloudflare/workers-types"; +import { expect, it } from "@effect/vitest"; +import * as Savings from "./Savings.ts"; +import * as Layer from "effect/Layer"; + +it.effect( + "increments lifetime totals once per receipt, including retries and different hosts", + () => + Effect.gen(function* () { + const sqlite = new NodeSqlite.DatabaseSync(":memory:"); + try { + sqlite.exec( + NodeFS.readFileSync( + new URL("../migrations/savings/0001_savings.sql", import.meta.url), + "utf8", + ), + ); + sqlite.exec( + NodeFS.readFileSync( + new URL("../migrations/savings/0002_unspecified_tier.sql", import.meta.url), + "utf8", + ), + ); + const db = { + prepare: (sql: string) => ({ bind: (...args: (string | number)[]) => ({ sql, args }) }), + batch: async (statements: { sql: string; args: (string | number)[] }[]) => { + sqlite.exec("BEGIN"); + try { + for (const statement of statements) + sqlite.prepare(statement.sql).run(...statement.args); + sqlite.exec("COMMIT"); + } catch (error) { + sqlite.exec("ROLLBACK"); + throw error; + } + return []; + }, + } as unknown as Cloudflare.D1Database; + const savings = yield* Savings.Savings.pipe( + Effect.provide(Savings.layer.pipe(Layer.provide(Layer.succeed(Savings.Database, db)))), + ); + const summary = { + id: "a".repeat(64), + recordedAt: 1, + host: "mbp", + platform: "darwin", + arch: "arm64", + ci: false, + runId: "", + tasks: [{ package: "server", task: "check", source: "remote" as const, savedMs: 900 }], + }; + yield* savings.record(summary, "alice"); + yield* savings.record(summary, "alice"); + yield* savings.record({ ...summary, id: "b".repeat(64), host: "linux" }, "bob"); + yield* savings.record({ ...summary, id: "c".repeat(64) }, "alice"); + const rows = sqlite + .prepare("SELECT developer, host, remote_ms, remote_hits FROM savings ORDER BY developer") + .all(); + expect(rows).toEqual([ + { developer: "alice", host: "mbp", remote_ms: 1800, remote_hits: 2 }, + { developer: "bob", host: "linux", remote_ms: 900, remote_hits: 1 }, + ]); + } finally { + sqlite.close(); + } + }), +); diff --git a/infra/task-cache/src/Savings.ts b/infra/task-cache/src/Savings.ts new file mode 100644 index 0000000000000..64723e851c45f --- /dev/null +++ b/infra/task-cache/src/Savings.ts @@ -0,0 +1,140 @@ +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Clock from "effect/Clock"; +import type * as Cloudflare from "@cloudflare/workers-types"; +import type { Summary } from "./Summary.ts"; + +interface Totals { + developer: string; + host: string; + platform: string; + arch: string; + ci: number; + local_ms: number; + unspecified_ms: number; + unspecified_hits: number; + remote_ms: number; + local_hits: number; + remote_hits: number; + executed: number; + restore_failures: number; +} + +export class SavingsError extends Schema.TaggedError()("SavingsError", { + operation: Schema.Literals(["record", "read", "cleanup"]), + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to ${this.operation} task-cache savings.`; + } +} + +export class Database extends Context.Service()( + "@t3tools/task-cache/Savings/Database", +) {} + +export class Savings extends Context.Service< + Savings, + { + readonly record: ( + summary: typeof Summary.Type, + developer: string, + ) => Effect.Effect; + readonly emitTotals: (developer?: string, host?: string) => Effect.Effect; + readonly cleanup: Effect.Effect; + } +>()("@t3tools/task-cache/Savings") {} + +const make = Effect.gen(function* () { + const database = yield* Database; + const record = Effect.fn("TaskCache.persistSavings")(function* ( + summary: typeof Summary.Type, + developer: string, + ) { + const local = summary.tasks.filter((task) => task.source === "local"); + const unspecified = summary.tasks.filter((task) => task.source === "unspecified"); + const remote = summary.tasks.filter((task) => task.source === "remote"); + // D1 batch is transactional. changes() gates the increment on the preceding receipt insertion. + yield* Effect.tryPromise({ + try: () => + database.batch([ + database + .prepare("INSERT INTO receipts(id, recorded_at) VALUES (?, ?) ON CONFLICT DO NOTHING") + .bind(summary.id, summary.recordedAt), + database + .prepare(`INSERT INTO savings(developer, host, platform, arch, ci, local_ms, remote_ms, local_hits, remote_hits, executed, restore_failures, unspecified_ms, unspecified_hits) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE changes() = 1 + ON CONFLICT DO UPDATE SET + local_ms = local_ms + excluded.local_ms, remote_ms = remote_ms + excluded.remote_ms, + local_hits = local_hits + excluded.local_hits, remote_hits = remote_hits + excluded.remote_hits, + executed = executed + excluded.executed, restore_failures = restore_failures + excluded.restore_failures, + unspecified_ms = unspecified_ms + excluded.unspecified_ms, unspecified_hits = unspecified_hits + excluded.unspecified_hits`) + .bind( + developer, + summary.host, + summary.platform, + summary.arch, + Number(summary.ci), + local.reduce((sum, task) => sum + task.savedMs, 0), + remote.reduce((sum, task) => sum + task.savedMs, 0), + local.length, + remote.length, + summary.tasks.filter((task) => task.source === "executed").length, + summary.tasks.filter((task) => task.source === "failed").length, + unspecified.reduce((sum, task) => sum + task.savedMs, 0), + unspecified.length, + ), + ]), + catch: (cause) => new SavingsError({ operation: "record", cause }), + }); + }); + + const emitTotals = Effect.fn("TaskCache.emitTotals")(function* ( + developer?: string, + host?: string, + ) { + const rows = yield* Effect.tryPromise({ + try: () => + (developer === undefined + ? database.prepare("SELECT * FROM savings") + : database + .prepare("SELECT * FROM savings WHERE developer = ? AND host = ?") + .bind(developer, host) + ).all(), + catch: (cause) => new SavingsError({ operation: "read", cause }), + }); + yield* Effect.forEach(rows.results, (row) => + Effect.annotateCurrentSpan({ + "developer.id": row.developer, + "host.name": row.host, + "host.os": row.platform, + "host.arch": row.arch, + ci: Boolean(row.ci), + "cache.local_saved_ms": row.local_ms, + "cache.unspecified_saved_ms": row.unspecified_ms, + "cache.unspecified_hits": row.unspecified_hits, + "cache.remote_saved_ms": row.remote_ms, + "cache.local_hits": row.local_hits, + "cache.remote_hits": row.remote_hits, + "cache.executed": row.executed, + "cache.restore_failures": row.restore_failures, + }).pipe(Effect.withSpan("TaskCache.lifetime")), + ); + }); + + const cleanup = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + yield* Effect.tryPromise({ + try: () => + database + .prepare("DELETE FROM receipts WHERE recorded_at < ?") + .bind(now - 31 * 86400_000) + .run(), + catch: (cause) => new SavingsError({ operation: "cleanup", cause }), + }); + }); + return Savings.of({ record, emitTotals, cleanup }); +}); + +export const layer = Layer.effect(Savings, make); diff --git a/infra/task-cache/src/Summary.ts b/infra/task-cache/src/Summary.ts new file mode 100644 index 0000000000000..320d6fd43a2bd --- /dev/null +++ b/infra/task-cache/src/Summary.ts @@ -0,0 +1,64 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const Label = Schema.String.check(Schema.isMaxLength(256)); +const Count = Schema.Number.check( + Schema.isInt(), + Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), +); +export const Summary = Schema.Struct({ + id: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + recordedAt: Count, + host: Label, + platform: Label, + arch: Label, + ci: Schema.Boolean, + runId: Label, + tasks: Schema.Array( + Schema.Struct({ + package: Label, + task: Label, + source: Schema.Literals(["local", "remote", "unspecified", "executed", "failed"]), + savedMs: Count, + }), + ).check(Schema.isMaxLength(1000)), +}); + +export const recordSummary = Effect.fn("TaskCache.summary")(function* ( + summary: typeof Summary.Type, + developer: string, +) { + const local = summary.tasks.filter((task) => task.source === "local"); + const unspecified = summary.tasks.filter((task) => task.source === "unspecified"); + const remote = summary.tasks.filter((task) => task.source === "remote"); + yield* Effect.annotateCurrentSpan({ + "cache.event_id": summary.id, + "cache.recorded_at": summary.recordedAt, + "developer.id": developer, + "host.name": summary.host, + "host.os": summary.platform, + "host.arch": summary.arch, + ci: summary.ci, + "ci.run_id": summary.runId, + "cache.local_hits": local.length, + "cache.unspecified_hits": unspecified.length, + "cache.unspecified_saved_ms": unspecified.reduce((sum, task) => sum + task.savedMs, 0), + "cache.remote_hits": remote.length, + "cache.executed": summary.tasks.filter((task) => task.source === "executed").length, + "cache.restore_failures": summary.tasks.filter((task) => task.source === "failed").length, + "cache.local_saved_ms": local.reduce((sum, task) => sum + task.savedMs, 0), + "cache.remote_saved_ms": remote.reduce((sum, task) => sum + task.savedMs, 0), + }); + yield* Effect.forEach(summary.tasks, (task, index) => + Effect.annotateCurrentSpan({ + "cache.event_id": summary.id, + "cache.task_index": index, + "developer.id": developer, + "host.name": summary.host, + "task.package": task.package, + "task.name": task.task, + "cache.source": task.source, + "cache.saved_ms": task.savedMs, + }).pipe(Effect.withSpan("TaskCache.task")), + ); +}); diff --git a/infra/task-cache/src/WriterRegistry.test.ts b/infra/task-cache/src/WriterRegistry.test.ts new file mode 100644 index 0000000000000..449bdf5b13270 --- /dev/null +++ b/infra/task-cache/src/WriterRegistry.test.ts @@ -0,0 +1,247 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as WriterRegistry from "./WriterRegistry.ts"; +import { WriterSecrets } from "./deploymentConfig.ts"; + +const readKeys = Config.Redacted("DEVELOPER_KEYS").pipe(Effect.provide(WriterSecrets)); + +it.effect("loads the private writer registry as deployment configuration", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(`${home}/.config/t3-task-cache`, { recursive: true }); + yield* fs.writeFileString(`${home}/.config/t3-task-cache/writers.json`, '{"maria":"digest"}'); + yield* fs.writeFileString( + `${home}/.config/t3-task-cache/readers.json`, + '{"ci-reader":"digest"}', + ); + const keys = yield* readKeys.pipe( + Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home }))), + ); + expect(JSON.parse(Redacted.value(keys))).toEqual({ maria: "digest" }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("uses explicit deployment credentials without requiring a local registry", () => + Effect.gen(function* () { + const keys = yield* readKeys.pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ DEVELOPER_KEYS: '{"theo":"digest"}', READER_KEYS: "{}" }), + ), + ), + ); + expect(JSON.parse(Redacted.value(keys))).toEqual({ theo: "digest" }); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("fails instead of deploying an empty registry when credentials are missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const result = yield* readKeys.pipe( + Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home }))), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("rejects reserved writer names without modifying credential stores", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const home = yield* fs.makeTempDirectoryScoped(); + const state = `${home}/.config/t3-task-cache`; + yield* fs.makeDirectory(state, { recursive: true }); + const writers = '{"maria":"digest"}'; + const credentials = '{"https://cache.example":{"name":"maria","token":"secret"}}'; + yield* fs.writeFileString(`${state}/writers.json`, writers); + yield* fs.writeFileString(`${state}/credentials.json`, credentials); + for (const name of ["writers", "credentials", "readers", "ci-reader"]) { + const code = yield* spawner.exitCode( + ChildProcess.make( + "node", + [`${import.meta.dirname}/../scripts/manage.ts`, "add-writer", name], + { + env: { HOME: home }, + extendEnv: true, + stdout: "ignore", + stderr: "ignore", + }, + ), + ); + expect(code).not.toBe(0); + expect(yield* fs.readFileString(`${state}/writers.json`)).toBe(writers); + expect(yield* fs.readFileString(`${state}/credentials.json`)).toBe(credentials); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("writes credentials for the supplied deployed worker endpoint", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const endpoint = "https://preview-cache.example/projects/t3code"; + const file = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("maria", false, endpoint, true)), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home }))), + ), + ), + ); + expect(file).toBe(`${home}/.config/t3-task-cache/maria.json`); + if (!file) return yield* Effect.die("Expected a credential file"); + const credential = JSON.parse(yield* fs.readFileString(file)); + expect(credential.endpoint).toBe(endpoint); + expect(credential.name).toBe("maria"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("refuses a concurrent registry update without changing committed credentials", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const state = `${home}/.config/t3-task-cache`; + yield* fs.makeDirectory(`${state}/writers.lock`, { recursive: true }); + yield* fs.writeFileString(`${state}/writers.json`, '{"maria":"digest"}'); + const result = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("theo", false, "https://cache.example")), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home }))), + ), + ), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* fs.readFileString(`${state}/writers.json`)).toBe('{"maria":"digest"}'); + expect(yield* fs.exists(`${state}/theo.json`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("keeps the committed registry intact if a replacement write fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const state = `${home}/.config/t3-task-cache`; + yield* fs.makeDirectory(state, { recursive: true }); + const original = '{"maria":"digest"}'; + yield* fs.writeFileString(`${state}/writers.json`, original); + const result = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("theo", false, "https://cache.example")), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide([ + ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home })), + Layer.succeed(FileSystem.FileSystem, { + ...fs, + writeFileString: (path) => + fs + .writeFileString(path, "partial") + .pipe(Effect.andThen(Effect.die("disk write failed"))), + }), + ]), + ), + ), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* fs.readFileString(`${state}/writers.json`)).toBe(original); + expect(yield* fs.exists(`${state}/writers.lock`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("preserves filesystem errors when acquiring the registry lock", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const denied = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + }); + const error = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("theo", false, "https://cache.example")), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide([ + ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home })), + Layer.succeed(FileSystem.FileSystem, { + ...fs, + makeDirectory: (path, options) => + path.endsWith("writers.lock") + ? Effect.fail(denied) + : fs.makeDirectory(path, options), + }), + ]), + ), + ), + Effect.flip, + ); + expect(error).toBe(denied); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("refuses to initialize a missing writer registry implicitly", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const result = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("theo", false, "https://cache.example")), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home }))), + ), + ), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* fs.exists(`${home}/.config/t3-task-cache/writers.json`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("keeps existing authorization if credential publication fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const state = `${home}/.config/t3-task-cache`; + yield* fs.makeDirectory(state, { recursive: true }); + const original = '{"maria":"old-digest"}'; + yield* fs.writeFileString(`${state}/writers.json`, original); + const error = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + }); + const result = yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.update("maria", false, "https://cache.example")), + Effect.provide( + WriterRegistry.layer.pipe( + Layer.provide([ + ConfigProvider.layer(ConfigProvider.fromUnknown({ HOME: home })), + Layer.succeed(FileSystem.FileSystem, { + ...fs, + rename: (from, to) => + to === `${state}/maria.json` ? Effect.fail(error) : fs.rename(from, to), + }), + ]), + ), + ), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* fs.readFileString(`${state}/writers.json`)).toBe(original); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/infra/task-cache/src/WriterRegistry.ts b/infra/task-cache/src/WriterRegistry.ts new file mode 100644 index 0000000000000..0032f66e292d1 --- /dev/null +++ b/infra/task-cache/src/WriterRegistry.ts @@ -0,0 +1,128 @@ +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import type * as PlatformError from "effect/PlatformError"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +const Writers = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); +const decodeWriters = Schema.decodeEffect(Writers); +const encodeWriters = Schema.encodeEffect(Writers); + +const stateDirectory = Config.String("HOME").pipe( + Effect.map((home) => `${home}/.config/t3-task-cache`), +); + +const Credential = Schema.fromJsonString( + Schema.Struct({ endpoint: Schema.String, name: Schema.String, token: Schema.String }), +); +class ManagementError extends Schema.TaggedError()("ManagementError", { + message: Schema.String, +}) {} +export const isWriterName = (name: string) => + /^(?!writers$|credentials$|readers$|ci-reader$)[a-z0-9-]{1,64}$/.test(name); + +const encodeCredential = Schema.encodeEffect(Credential); + +export class WriterRegistry extends Context.Service< + WriterRegistry, + { + readonly read: () => Effect.Effect< + Record, + PlatformError.PlatformError | Schema.SchemaError + >; + readonly update: ( + name: string, + revoke: boolean, + endpoint?: string, + initialize?: boolean, + ) => Effect.Effect< + string | undefined, + PlatformError.PlatformError | Schema.SchemaError | ManagementError + >; + } +>()("@t3tools/task-cache/WriterRegistry") {} + +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const state = yield* stateDirectory; + const readWriters = Effect.fn("TaskCache.readWriters")(function* () { + return yield* decodeWriters(yield* fs.readFileString(`${state}/writers.json`)); + }); + + const save = Effect.fn("TaskCache.save")(function* ( + entries: ReadonlyArray, + ) { + yield* fs.makeDirectory(state, { recursive: true, mode: 0o700 }); + yield* Effect.scoped( + Effect.gen(function* () { + const directory = yield* fs.makeTempDirectoryScoped({ + directory: state, + prefix: ".writer-", + }); + for (const [name, value] of entries) + yield* fs.writeFileString(`${directory}/${name}.json`, value + "\n", { mode: 0o600 }); + // The registry is the authorization commit point and must publish last. + for (const [name] of entries) + yield* fs.rename(`${directory}/${name}.json`, `${state}/${name}.json`); + }), + ); + }); + + const updateWriter = Effect.fn("TaskCache.updateWriter")(function* ( + name: string, + revoke: boolean, + endpoint?: string, + initialize = false, + ) { + if (!isWriterName(name)) return yield* new ManagementError({ message: "Invalid writer name." }); + if (!revoke && !endpoint) + return yield* new ManagementError({ message: "A deployed cache endpoint is required." }); + return yield* Effect.scoped( + Effect.gen(function* () { + yield* fs.makeDirectory(state, { recursive: true, mode: 0o700 }); + // Exclusive creation coordinates separate management processes as well as fibers. + yield* Effect.acquireRelease( + fs.makeDirectory(`${state}/writers.lock`).pipe( + Effect.mapError((error) => + error.reason._tag === "AlreadyExists" + ? new ManagementError({ + message: + "Writer registry is locked by another update. Retry after it completes; remove writers.lock only if that process has stopped.", + }) + : error, + ), + ), + () => fs.remove(`${state}/writers.lock`, { recursive: true }).pipe(Effect.orDie), + ); + const exists = yield* fs.exists(`${state}/writers.json`); + if (!exists && !initialize) + return yield* new ManagementError({ + message: + "Writer registry is missing. Copy the existing private registry from its administrator; use --new-registry only when creating a new cache.", + }); + const writers = { ...(exists || revoke ? yield* readWriters() : {}) }; + const token = hex(yield* crypto.randomBytes(32)); + if (revoke) delete writers[name]; + else { + writers[name] = hex(yield* crypto.digest("SHA-256", new TextEncoder().encode(token))); + } + const entries: Array = []; + if (!revoke && endpoint) + entries.push([name, yield* encodeCredential({ endpoint, name, token })]); + entries.push(["writers", yield* encodeWriters(writers)]); + yield* save(entries); + return revoke ? undefined : `${state}/${name}.json`; + }), + ); + }); + return WriterRegistry.of({ read: readWriters, update: updateWriter }); +}); + +export const layer = Layer.effect(WriterRegistry, make); + +const hex = (bytes: Uint8Array) => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); diff --git a/infra/task-cache/src/deploymentConfig.ts b/infra/task-cache/src/deploymentConfig.ts new file mode 100644 index 0000000000000..0377c0c34d1aa --- /dev/null +++ b/infra/task-cache/src/deploymentConfig.ts @@ -0,0 +1,39 @@ +import * as Layer from "effect/Layer"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as Schema from "effect/Schema"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as WriterRegistry from "./WriterRegistry.ts"; + +const encodeWriters = Schema.encodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), +); + +// An absent registry must fail deployment rather than revoke every existing writer. +export const WriterSecrets = ConfigProvider.layerAdd( + Effect.gen(function* () { + const configured = yield* Config.Redacted("DEVELOPER_KEYS").pipe(Config.option); + const readers = yield* Config.Redacted("READER_KEYS").pipe(Config.option); + const fs = yield* FileSystem.FileSystem; + const readerKeys = Option.isSome(readers) + ? {} + : { + READER_KEYS: yield* fs.readFileString( + `${yield* Config.String("HOME")}/.config/t3-task-cache/readers.json`, + ), + }; + if (Option.isSome(configured)) return ConfigProvider.fromUnknown(readerKeys); + return ConfigProvider.fromUnknown({ + ...readerKeys, + DEVELOPER_KEYS: yield* encodeWriters( + yield* WriterRegistry.WriterRegistry.pipe( + Effect.flatMap((registry) => registry.read()), + Effect.provide(WriterRegistry.layer.pipe(Layer.provide(NodeCrypto.layer))), + ), + ), + }); + }), +); diff --git a/infra/task-cache/src/developerAuth.test.ts b/infra/task-cache/src/developerAuth.test.ts new file mode 100644 index 0000000000000..1f28a1ad13ab4 --- /dev/null +++ b/infra/task-cache/src/developerAuth.test.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import { expect, it } from "@effect/vitest"; + +import { developerIdentity } from "./developerAuth.ts"; + +const token = "fixture-token-012345678901234567890123456789"; +const keys = JSON.stringify({ + julius: "210eb3d2c67a7207923e1999751300a87a2dd22839b258b80c3d805a6fe64984", +}); +const request = (credentials: string) => + new Request("https://cache.example/projects/t3code/store", { + headers: { authorization: `Basic ${btoa(credentials)}` }, + }); + +it.effect( + "accepts the registered developer and rejects wrong, revoked, or malformed credentials", + () => + Effect.gen(function* () { + expect(yield* developerIdentity(request(`julius:${token}`), keys)).toBe("julius"); + expect(yield* developerIdentity(request(`theo:${token}`), keys)).toBeNull(); + expect(yield* developerIdentity(request(`julius:${token}wrong`), keys)).toBeNull(); + expect(yield* developerIdentity(request(`julius:${token}`), "{}")).toBeNull(); + expect(yield* developerIdentity(request(`julius:${token}`), undefined)).toBeNull(); + expect(yield* developerIdentity(request("malformed"), keys)).toBeNull(); + expect(yield* developerIdentity(request(`constructor:${token}`), keys)).toBeNull(); + }), +); + +it.effect("leaves anonymous reads and GitHub OIDC authorization to the upstream Worker", () => + Effect.gen(function* () { + expect(yield* developerIdentity(new Request("https://cache.example"), keys)).toBeUndefined(); + const oidc = new Request("https://cache.example", { + headers: { authorization: "Bearer github-oidc-token" }, + }); + expect(yield* developerIdentity(oidc, keys)).toBeUndefined(); + }), +); diff --git a/infra/task-cache/src/developerAuth.ts b/infra/task-cache/src/developerAuth.ts new file mode 100644 index 0000000000000..f358a8961e2f8 --- /dev/null +++ b/infra/task-cache/src/developerAuth.ts @@ -0,0 +1,35 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const Keys = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); + +const decodeKeys = Schema.decodeEffect(Keys); + +export const developerIdentity = Effect.fn("TaskCache.developerIdentity")(function* ( + request: Request, + encodedKeys: string | undefined, +) { + const authorization = request.headers.get("authorization"); + if (!authorization?.toLowerCase().startsWith("basic ")) return undefined; + if (!encodedKeys || authorization.length > 1024) return null; + + const decoded = yield* Effect.try(() => atob(authorization.slice(6))).pipe(Effect.option); + if (decoded._tag === "None") return null; + const keys = yield* decodeKeys(encodedKeys).pipe(Effect.option); + if (keys._tag === "None") return null; + const separator = decoded.value.indexOf(":"); + const name = decoded.value.slice(0, separator); + const token = decoded.value.slice(separator + 1); + if (separator < 1 || !/^[a-z0-9-]{1,64}$/.test(name) || token.length < 32) return null; + const expected = Object.hasOwn(keys.value, name) ? keys.value[name] : undefined; + if (typeof expected !== "string" || !/^[a-f0-9]{64}$/.test(expected)) return null; + const digest = new Uint8Array( + yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(token))), + ); + const actual = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); + let difference = 0; + for (let index = 0; index < actual.length; index++) { + difference |= actual.charCodeAt(index) ^ expected.charCodeAt(index); + } + return difference === 0 ? name : null; +}); diff --git a/infra/task-cache/src/flush.ts b/infra/task-cache/src/flush.ts new file mode 100644 index 0000000000000..aef97bb126dee --- /dev/null +++ b/infra/task-cache/src/flush.ts @@ -0,0 +1,3 @@ +import { flushTelemetry } from "./telemetry.ts"; + +await flushTelemetry(); diff --git a/infra/task-cache/src/nativeHook.js b/infra/task-cache/src/nativeHook.js new file mode 100644 index 0000000000000..12eac748eb01d --- /dev/null +++ b/infra/task-cache/src/nativeHook.js @@ -0,0 +1,17 @@ +/** @param {string} source */ +export function patchNativeCli(source) { + if (source.includes('import { startRunHook } from "./run-hook.js";')) return source; + const start = "\tconst exitCode = await run({"; + const end = "\t\targs: rustCliArgs\n\t});"; + if (source.split(start).length !== 2 || source.split(end).length !== 2) + throw new Error("Unsupported Vite+ entrypoint; no files were changed."); + return ( + 'import { startRunHook } from "./run-hook.js";\n' + + source + .replace( + start, + '\tconst finishRunHook = await startRunHook(command, process.cwd(), async () => (await (await import("../binding/index.js")).detectWorkspace(process.cwd())).root);\n\tif (finishRunHook?.remoteCache && !rustCliArgs.includes("--last-details") && !rustCliArgs.some((arg) => arg === "--remote-cache" || arg.startsWith("--remote-cache="))) { rustCliArgs.splice(1, 0, "--remote-cache", finishRunHook.remoteCache); process.env.VP_T3_TASK_CACHE_MODE = finishRunHook.remoteCache; }\n\tlet exitCode;\n\ttry {\n\texitCode = await run({', + ) + .replace(end, end + "\n\t} finally {\n\t\tawait finishRunHook?.();\n\t}") + ); +} diff --git a/infra/task-cache/src/nativeHook.test.ts b/infra/task-cache/src/nativeHook.test.ts new file mode 100644 index 0000000000000..de8741a9206a4 --- /dev/null +++ b/infra/task-cache/src/nativeHook.test.ts @@ -0,0 +1,86 @@ +import { expect, it } from "vite-plus/test"; +import { patchNativeCli } from "./nativeHook.js"; + +it("wraps the native runner in a finally hook and patches only once", () => { + const original = + "\tconst exitCode = await run({\n\t\targs: rustCliArgs\n\t});\n\tprocess.exit(exitCode);"; + const patched = patchNativeCli(original); + expect(patched).toContain("await startRunHook(command,"); + expect(patched).toContain("finally {\n\t\tawait finishRunHook?.();"); + expect(patched).toContain("process.exit(exitCode)"); + expect(patchNativeCli(patched)).toBe(patched); +}); + +it("rejects an unknown native entrypoint before changing it", () => { + expect(() => patchNativeCli("changed upstream entrypoint")).toThrow( + "Unsupported Vite+ entrypoint", + ); +}); + +it("leaves an unsupported CLI runnable when the machine preload is enabled", async () => { + const fs = await import("node:fs/promises"); + const os = await import("node:os"); + const path = await import("node:path"); + const { promisify } = await import("node:util"); + const { execFile } = await import("node:child_process"); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cache-preload-")); + try { + const directory = path.join(root, "vite-plus/dist"); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(root, "package.json"), '{"type":"module"}'); + await fs.writeFile(path.join(directory, "bin.js"), 'console.log("unmodified CLI");'); + const result = await promisify(execFile)( + process.execPath, + ["--import", new URL("./preload.mjs", import.meta.url).href, path.join(directory, "bin.js")], + { env: { ...process.env, NODE_OPTIONS: "" } }, + ); + expect(result.stdout.trim()).toBe("unmodified CLI"); + expect(result.stderr).toBe(""); + await fs.mkdir(path.join(root, "vite-plus/binding")); + await fs.writeFile(path.join(root, "vite-plus/binding/index.js"), "export {};"); + await fs.writeFile( + path.join(directory, "bin.js"), + 'const command="--version"; const rustCliArgs=[]; const run=async()=>0;\n\tconst exitCode = await run({\n\t\targs: rustCliArgs\n\t});\nconsole.log("compatible CLI");', + ); + const compatible = await promisify(execFile)( + process.execPath, + ["--import", new URL("./preload.mjs", import.meta.url).href, path.join(directory, "bin.js")], + { env: { ...process.env, NODE_OPTIONS: "" } }, + ); + expect(compatible.stdout.trim()).toBe("compatible CLI"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +it("does not execute hook modules supplied by ancestor package manifests", async () => { + const fs = await import("node:fs/promises"); + const os = await import("node:os"); + const path = await import("node:path"); + const { promisify } = await import("node:util"); + const { execFile } = await import("node:child_process"); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cache-ancestor-")); + try { + await fs.mkdir(path.join(root, "project")); + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ vitePlus: { runHook: "./malicious.mjs" } }), + ); + await fs.writeFile( + path.join(root, "malicious.mjs"), + 'throw new Error("ancestor module executed")', + ); + const result = await promisify(execFile)( + process.execPath, + [ + "--input-type=module", + "-e", + `import { startRunHook } from ${JSON.stringify(new URL("./nativeRunHook.js", import.meta.url).href)}; await startRunHook("run", ${JSON.stringify(path.join(root, "project"))});`, + ], + { env: { ...process.env, HOME: root, NODE_OPTIONS: "" } }, + ); + expect(result.stderr).toBe(""); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/infra/task-cache/src/nativeRunHook.js b/infra/task-cache/src/nativeRunHook.js new file mode 100644 index 0000000000000..0d75ebcc2b7e6 --- /dev/null +++ b/infra/task-cache/src/nativeRunHook.js @@ -0,0 +1,65 @@ +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +async function readJson(file) { + try { + return JSON.parse(await NodeFSP.readFile(file, "utf8")); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } +} + +// Optional repository integration; never changes the task runner's exit status. +export async function startRunHook(command, cwd = process.cwd(), resolveWorkspaceRoot) { + if (command !== "run") return; + try { + const configured = await readJson( + NodePath.join(NodeOS.homedir(), ".config/vite-plus/run-hook.json"), + ); + let root = cwd; + while (true) { + let hook; + if (configured?.rootMarker && configured?.module) { + try { + await NodeFSP.access(NodePath.join(root, configured.rootMarker)); + hook = configured.module; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + if (typeof hook === "string") { + // Use Vite's own workspace resolver so nested standalone projects stay independent. + if (resolveWorkspaceRoot) { + const workspace = await resolveWorkspaceRoot(); + if (!workspace || (await NodeFSP.realpath(workspace)) !== (await NodeFSP.realpath(root))) + break; + } + const integration = await import(NodeURL.pathToFileURL(NodePath.resolve(root, hook)).href); + const finish = await integration.default({ root }); + return Object.assign( + async () => { + try { + await finish?.(); + } catch { + console.warn("[Vite+] Run hook failed to finish."); + } + }, + { remoteCache: finish?.remoteCache }, + ); + } + const parent = NodePath.dirname(root); + if (parent === root) break; + root = parent; + } + // Vite forwards our CLI mode to tasks, including agents working in other projects. + if ( + process.env.VP_T3_TASK_CACHE_MODE && + process.env.VP_REMOTE_CACHE === process.env.VP_T3_TASK_CACHE_MODE + ) + delete process.env.VP_REMOTE_CACHE; + } catch { + console.warn("[Vite+] Run hook failed to start."); + } +} diff --git a/infra/task-cache/src/observability.ts b/infra/task-cache/src/observability.ts new file mode 100644 index 0000000000000..fa5b96c201709 --- /dev/null +++ b/infra/task-cache/src/observability.ts @@ -0,0 +1,203 @@ +import * as Axiom from "alchemy/Axiom"; +import * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; +import { cacheName } from "./resources.ts"; + +// Native summaries may be observed by nested launchers and retried after a lost response. +// Deduplicate before summing; task runtime is a compute proxy, not measured CPU or critical-path time. +export const savingsQuery = ( + dataset: string, + group?: "developer" | "host" | "ci", + lifetime = false, +) => + [ + `['${dataset}']`, + `| where name == '${lifetime ? "TaskCache.lifetime" : "TaskCache.summary"}'`, + `| extend a = ['attributes.custom']`, + `| extend event_id = tostring(a['cache.event_id']), developer = tostring(a['developer.id']), host = tostring(['attributes.host.name']), ci = tobool(a['ci']), recorded_at = tolong(a['cache.recorded_at']), unspecified_ms = coalesce(tolong(a['cache.unspecified_saved_ms']), 0), unspecified_hits = coalesce(tolong(a['cache.unspecified_hits']), 0), local_ms = tolong(a['cache.local_saved_ms']), remote_ms = tolong(a['cache.remote_saved_ms']), local_hits = tolong(a['cache.local_hits']), remote_hits = tolong(a['cache.remote_hits']), executed = tolong(a['cache.executed']), restore_failures = tolong(a['cache.restore_failures'])`, + `| summarize arg_max(_time, unspecified_ms, unspecified_hits, local_ms, remote_ms, local_hits, remote_hits, executed, restore_failures${lifetime ? "" : ", developer, host, ci, recorded_at"}) by ${lifetime ? "developer, host, ci, platform = tostring(a['host.os']), arch = tostring(['attributes.host.arch'])" : "event_id"}`, + ...(!lifetime ? ["| extend _time = unixtime_milliseconds_todatetime(recorded_at)"] : []), + `| where local_hits + remote_hits + unspecified_hits + executed + restore_failures > 0`, + `| summarize saved_task_hours = sum(local_ms + remote_ms + unspecified_ms) / 3600000.0, remote_saved_hours = sum(remote_ms) / 3600000.0, local_saved_hours = sum(local_ms) / 3600000.0, unspecified_saved_hours = sum(unspecified_ms) / 3600000.0, unspecified_hits = sum(unspecified_hits), local_hits = sum(local_hits), remote_hits = sum(remote_hits), executed = sum(executed), restore_failures = sum(restore_failures)${group ? ` by ${group}` : ""}`, + ].join("\n"); + +const summaryRows = (dataset: string) => savingsQuery(dataset).split("\n").slice(0, -1).join("\n"); + +export const Observability = Effect.gen(function* () { + const name = yield* cacheName; + const traces = yield* Axiom.Dataset("CacheTraces", { + name: `${name}-traces`, + kind: "otel:traces:v1", + description: "Shared task cache: service traces and unsampled native task savings reports.", + retentionDays: 30, + useRetentionPeriod: true, + }); + const token = yield* Axiom.ApiToken("CacheIngestToken", { + name: `${name}-otel-ingest`, + datasetCapabilities: Output.map(traces.name, (dataset) => ({ + [dataset]: { ingest: ["create" as const] }, + })), + }); + const charts = [ + { + id: "total", + name: "⏳ Task hours given back · lifetime", + type: "Statistic", + x: 0, + y: 0, + w: 3, + h: 3, + query: (dataset: string) => + `${savingsQuery(dataset, undefined, true)}\n| project saved_task_hours`, + }, + { + id: "remote", + name: "🌍 Remote hours shared · lifetime", + type: "Statistic", + x: 3, + y: 0, + w: 3, + h: 3, + query: (dataset: string) => + `${savingsQuery(dataset, undefined, true)}\n| project remote_saved_hours`, + }, + { + id: "hit-rate", + name: "🎯 Cache hit rate · lifetime (%)", + type: "Statistic", + x: 6, + y: 0, + w: 3, + h: 3, + query: (dataset: string) => + `${savingsQuery(dataset, undefined, true)}\n| project hit_rate = round(100.0 * (local_hits + remote_hits + unspecified_hits) / (local_hits + remote_hits + unspecified_hits + executed), 1)`, + }, + { + id: "skipped", + name: "⚡ Tasks we skipped · lifetime", + type: "Statistic", + x: 9, + y: 0, + w: 3, + h: 3, + query: (dataset: string) => + `${savingsQuery(dataset, undefined, true)}\n| project cached_tasks = local_hits + remote_hits + unspecified_hits`, + }, + { + id: "savings-trend", + name: "📈 Minutes given back over time", + type: "TimeSeries", + x: 0, + y: 3, + w: 8, + h: 5, + query: (dataset: string) => + `${summaryRows(dataset)}\n| summarize local_minutes = sum(local_ms) / 60000.0, remote_minutes = sum(remote_ms) / 60000.0, unspecified_minutes = sum(unspecified_ms) / 60000.0 by bin_auto(_time)`, + }, + { + id: "developer", + name: "🏆 Who is saving the most?", + type: "Pie", + x: 8, + y: 3, + w: 4, + h: 5, + query: (dataset: string) => + `${savingsQuery(dataset, "developer", true)}\n| project developer, saved_task_hours\n| order by saved_task_hours desc`, + }, + { + id: "tasks-trend", + name: "🔥 Cache hits vs actual execution", + type: "TimeSeries", + x: 0, + y: 8, + w: 8, + h: 5, + query: (dataset: string) => + `${summaryRows(dataset)}\n| summarize local_hits = sum(local_hits), remote_hits = sum(remote_hits), unspecified_hits = sum(unspecified_hits), executed = sum(executed) by bin_auto(_time)`, + }, + { + id: "host", + name: "🖥️ The machine leaderboard", + type: "Pie", + x: 8, + y: 8, + w: 4, + h: 5, + query: (dataset: string) => + `${savingsQuery(dataset, "host", true)}\n| project host, saved_task_hours\n| order by saved_task_hours desc`, + }, + { + id: "leaderboard", + name: "🏅 Developer scorecard · lifetime", + type: "Table", + x: 0, + y: 13, + w: 8, + h: 4, + query: (dataset: string) => + `${savingsQuery(dataset, "developer", true)}\n| project developer, saved_task_hours, remote_saved_hours, cached_tasks = local_hits + remote_hits + unspecified_hits, executed\n| order by saved_task_hours desc`, + }, + { + id: "ci", + name: "🤖 CI vs developer machines · hours saved", + type: "Pie", + x: 8, + y: 13, + w: 4, + h: 4, + query: (dataset: string) => + `${savingsQuery(dataset, "ci", true)}\n| summarize saved_task_hours = sum(saved_task_hours) by environment = iff(ci, "CI", "Developer machines")`, + }, + ] as const; + for (const chart of charts) { + yield* Axiom.View(`CacheSavings-${chart.id}`, { + name: `${name}-${chart.id}-savings`, + description: chart.name, + datasets: [traces.name], + aplQuery: Output.map(traces.name, chart.query), + }); + } + const dashboard = yield* Axiom.Dashboard("CacheSavingsDashboard", { + dashboard: { + name: "⚡ Task Cache · Time Back", + owner: "", + schemaVersion: 2, + refreshTime: 60, + description: + "Avoided task execution time, not CPU seconds or elapsed developer time. Local and remote hits; deduplicated native Vite+ summaries. Lifetime counters are retained in D1 and refreshed hourly. Raw traces have 30-day retention.", + timeWindowStart: "qr-now-30d", + timeWindowEnd: "qr-now", + charts: charts.map((chart) => { + const common = { + id: chart.id, + name: chart.name, + query: { apl: Output.map(traces.name, chart.query) }, + }; + switch (chart.type) { + case "Statistic": + return { ...common, type: "Statistic" as const }; + case "TimeSeries": + return { + ...common, + type: "TimeSeries" as const, + query: { + ...common.query, + queryOptions: { + timeSeriesVariant: chart.id === "savings-trend" ? "area" : "bars", + displayNull: "zero", + overlayCharts: "true", + }, + }, + }; + case "Pie": + return { ...common, type: "Pie" as const }; + case "Table": + return { ...common, type: "Table" as const }; + } + }), + layout: charts.map(({ id, x, y, w, h }) => ({ i: id, x, y, w, h })), + }, + }); + return { traces, token, dashboard }; +}); diff --git a/infra/task-cache/src/preload.mjs b/infra/task-cache/src/preload.mjs new file mode 100644 index 0000000000000..a76bfe2069c96 --- /dev/null +++ b/infra/task-cache/src/preload.mjs @@ -0,0 +1,42 @@ +import * as NodeModule from "node:module"; + +// Vite forwards explicit modes to tasks; clear our inherited default even on older Node. +if ( + process.env.VP_T3_TASK_CACHE_MODE && + process.env.VP_REMOTE_CACHE === process.env.VP_T3_TASK_CACHE_MODE +) + delete process.env.VP_REMOTE_CACHE; + +// Older Node processes in unrelated projects must remain unaffected by machine setup. +if ( + typeof NodeModule.registerHooks === "function" && + Number(process.versions.node.split(".")[0]) >= 24 +) { + try { + const { patchNativeCli } = await import("./nativeHook.js"); + const hookUrl = new URL("./nativeRunHook.js", import.meta.url).href; + NodeModule.registerHooks({ + load(url, context, nextLoad) { + const loaded = nextLoad(url, context); + if (!url.endsWith("/vite-plus/dist/bin.js")) return loaded; + try { + const source = + typeof loaded.source === "string" + ? loaded.source + : Buffer.from(loaded.source).toString(); + return { + ...loaded, + source: patchNativeCli(source).replace( + 'from "./run-hook.js"', + `from ${JSON.stringify(hookUrl)}`, + ), + }; + } catch { + return loaded; + } + }, + }); + } catch { + // Restricted Node processes must still run when the optional hook cannot load. + } +} diff --git a/infra/task-cache/src/protocol.ts b/infra/task-cache/src/protocol.ts new file mode 100644 index 0000000000000..0f8cf83ddc9a4 --- /dev/null +++ b/infra/task-cache/src/protocol.ts @@ -0,0 +1,21 @@ +import { githubKeys } from "../vendor/vite-plus-remote-cache-cloudflare/src/auth.ts"; + +export { cborResponse } from "../vendor/vite-plus-remote-cache-cloudflare/src/cbor.ts"; +export { + cleanup, + getScope, + selectBlob, +} from "../vendor/vite-plus-remote-cache-cloudflare/src/database.ts"; +export { + errorResponse, + HttpError, +} from "../vendor/vite-plus-remote-cache-cloudflare/src/errors.ts"; +export { fetchMetadata } from "../vendor/vite-plus-remote-cache-cloudflare/src/fetch.ts"; +export { defaults } from "../vendor/vite-plus-remote-cache-cloudflare/src/limits.ts"; +export { store } from "../vendor/vite-plus-remote-cache-cloudflare/src/store.ts"; +export { readBody, Deadline } from "../vendor/vite-plus-remote-cache-cloudflare/src/streams.ts"; +export { Observations } from "../vendor/vite-plus-remote-cache-cloudflare/src/observations.ts"; + +export const keys = githubKeys(); + +export { authorizeCacheWrite, authorizeTelemetry } from "./telemetryAuth.ts"; diff --git a/infra/task-cache/src/protocolTypes.ts b/infra/task-cache/src/protocolTypes.ts new file mode 100644 index 0000000000000..d3ec7e328c971 --- /dev/null +++ b/infra/task-cache/src/protocolTypes.ts @@ -0,0 +1,14 @@ +import type * as Cloudflare from "@cloudflare/workers-types"; + +export interface ProtocolBindings { + readonly INDEX: Cloudflare.D1Database; + readonly ARTIFACTS: Omit & { + get(key: string): Promise< + | (Omit & { + readonly body: ReadableStream; + }) + | null + >; + }; + readonly GC_BATCH_SIZE: string; +} diff --git a/infra/task-cache/src/remoteCache.ts b/infra/task-cache/src/remoteCache.ts new file mode 100644 index 0000000000000..5493dbe66dcfc --- /dev/null +++ b/infra/task-cache/src/remoteCache.ts @@ -0,0 +1,30 @@ +// @effect-diagnostics nodeBuiltinImport:off - Vite configuration also loads before dependency installation. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +export function remoteCacheUrl(root: string, env = process.env, home = NodeOS.homedir()) { + if (env.VP_REMOTE_CACHE_URL) return env.VP_REMOTE_CACHE_URL; + try { + const { endpoint } = JSON.parse( + NodeFS.readFileSync(NodePath.join(root, ".task-cache.json"), "utf8"), + ); + const credentials = JSON.parse( + NodeFS.readFileSync(NodePath.join(home, ".config/t3-task-cache/credentials.json"), "utf8"), + ); + const credential = credentials[endpoint]; + if ( + typeof endpoint !== "string" || + typeof credential?.name !== "string" || + typeof credential.token !== "string" + ) + return; + const url = new URL(endpoint); + url.username = credential.name; + url.password = credential.token; + return url.href; + } catch { + // Missing or damaged optional setup must not prevent ordinary project commands. + return; + } +} diff --git a/infra/task-cache/src/resources.ts b/infra/task-cache/src/resources.ts new file mode 100644 index 0000000000000..0fb5ba169643a --- /dev/null +++ b/infra/task-cache/src/resources.ts @@ -0,0 +1,46 @@ +import * as Alchemy from "alchemy"; +import { adopt } from "alchemy/AdoptPolicy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import { retain } from "alchemy/RemovalPolicy"; +import * as Effect from "effect/Effect"; +import * as NodeURL from "node:url"; + +const protocol = new URL("../vendor/vite-plus-remote-cache-cloudflare/", import.meta.url); + +export const cacheName = Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + return stage === "prod" ? "t3-task-cache" : `t3-task-cache-${stage}`; +}); + +export const Index = cacheName.pipe( + Effect.flatMap((name) => + Cloudflare.D1.Database("Index", { + name, + migrations: NodeURL.fileURLToPath(new URL("migrations", protocol)), + }).pipe(adopt(true), retain()), + ), +); + +export const Artifacts = cacheName.pipe( + Effect.flatMap((name) => + Cloudflare.R2.Bucket("Artifacts", { + name, + lifecycleRules: [ + { + id: "remote-cache-generations", + deleteObjectsTransition: { condition: { type: "Age", maxAge: 9 * 86400 } }, + abortMultipartUploadsTransition: { condition: { type: "Age", maxAge: 86400 } }, + }, + ], + }).pipe(adopt(true), retain()), + ), +); + +export const Savings = cacheName.pipe( + Effect.flatMap((name) => + Cloudflare.D1.Database("Savings", { + name: `${name}-savings`, + migrations: NodeURL.fileURLToPath(new URL("../migrations/savings", import.meta.url)), + }).pipe(retain()), + ), +); diff --git a/infra/task-cache/src/runHook.test.ts b/infra/task-cache/src/runHook.test.ts new file mode 100644 index 0000000000000..727890ec2e87c --- /dev/null +++ b/infra/task-cache/src/runHook.test.ts @@ -0,0 +1,496 @@ +// @effect-diagnostics nodeBuiltinImport:off - exercises the real native runner and local HTTP collector. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { expect, it } from "vite-plus/test"; + +it("uses one machine hook across fresh worktrees without modifying Vite+", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-hook-")); + const unrelated = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "unrelated-project-")); + const reports: { authorization: string | undefined; body: string }[] = []; + let firstExit!: () => void; + const firstExited = new Promise((resolve) => { + firstExit = resolve; + }); + let completedReports = 0; + let notified: (() => void) | undefined; + let firstDisconnected = false; + const report = (count: number) => + completedReports >= count + ? Promise.resolve() + : new Promise((resolve) => { + notified = resolve; + }); + const server = NodeHttp.createServer(async (request, response) => { + if (!request.url?.endsWith("/telemetry")) { + response.writeHead(404).end(); + return; + } + let body = ""; + for await (const chunk of request) body += chunk; + reports.push({ authorization: request.headers.authorization, body }); + if (reports.length === 1) { + await firstExited; + firstDisconnected = response.destroyed; + } + response.writeHead(204).end(); + completedReports++; + notified?.(); + notified = undefined; + }); + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing HTTP address"); + const runner = NodePath.join(root, "vite-plus"); + const distribution = await NodeFSP.realpath( + NodePath.resolve(import.meta.dirname, "../../../node_modules/vite-plus/dist"), + ); + await NodeFSP.cp(distribution, NodePath.join(runner, "dist"), { recursive: true }); + await NodeFSP.copyFile( + NodePath.join(distribution, "../package.json"), + NodePath.join(runner, "package.json"), + ); + await NodeFSP.symlink( + NodePath.resolve(distribution, "../binding"), + NodePath.join(runner, "binding"), + ); + await NodeFSP.symlink( + NodePath.resolve(distribution, "../.."), + NodePath.join(runner, "node_modules"), + ); + const entrypoint = NodePath.join(runner, "dist/bin.js"); + await NodeFSP.writeFile( + NodePath.join(unrelated, "package.json"), + JSON.stringify({ name: "unrelated", scripts: { build: 'node -e "console.log(7)"' } }), + ); + const nested = NodePath.join(root, "nested"); + await NodeFSP.mkdir(nested); + await NodeFSP.writeFile( + NodePath.join(nested, "package.json"), + JSON.stringify({ name: "nested", scripts: { build: 'node -e "console.log(8)"' } }), + ); + await NodeFSP.writeFile(NodePath.join(nested, "pnpm-workspace.yaml"), "packages: []\n"); + const endpoint = `http://127.0.0.1:${address.port}/projects/test`; + await NodeFSP.writeFile(NodePath.join(root, ".task-cache.json"), JSON.stringify({ endpoint })); + await NodeFSP.writeFile( + NodePath.join(root, "package.json"), + JSON.stringify({ + name: "hook-fixture", + private: true, + scripts: { + build: `node build.cjs ${JSON.stringify(entrypoint)} ${JSON.stringify(unrelated)}`, + fail: 'node -e "process.exit(7)"', + nested: `node ${JSON.stringify(entrypoint)} -C ${JSON.stringify(nested)} run --no-cache build`, + }, + }), + ); + await NodeFSP.writeFile( + NodePath.join(root, "build.cjs"), + 'require("node:fs").writeFileSync("environment.json", JSON.stringify({url: process.env.VP_REMOTE_CACHE_URL ?? null, mode: process.env.VP_REMOTE_CACHE ?? null})); console.log(42); try {for (const cwd of process.argv.slice(3)) require("node:child_process").execFileSync(process.execPath, [process.argv[2], "run", "--no-cache", "build"], {cwd, encoding:"utf8"})} catch (error) { throw new Error(error.stdout + error.stderr) }', + ); + await NodeFSP.writeFile( + NodePath.join(root, "vite.config.ts"), + `import { remoteCacheUrl } from ${JSON.stringify(new URL("./remoteCache.ts", import.meta.url).href)}; export default {run:{cache:{scripts:true,remote:{url:remoteCacheUrl(import.meta.dirname)}}}};`, + ); + await NodeFSP.mkdir(NodePath.join(root, ".config/t3-task-cache"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(root, ".config/t3-task-cache/credentials.json"), + JSON.stringify({ [endpoint]: { name: "maria", token: "secret" } }), + ); + const configDirectory = NodePath.join(root, ".config/vite-plus"); + await NodeFSP.mkdir(configDirectory, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(configDirectory, "run-hook.json"), + JSON.stringify({ + rootMarker: ".task-cache.json", + module: NodePath.join(import.meta.dirname, "runHook.ts"), + }), + ); + const originalEntrypoint = await NodeFSP.readFile(entrypoint, "utf8"); + const run = (task: string, cwd = root, ci = false, cache = "--cache") => + new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn( + process.execPath, + [entrypoint, "run", ...(task === "--last-details" ? [] : [cache]), task], + { + cwd, + env: { + ...process.env, + HOME: root, + GITHUB_ACTIONS: ci ? "true" : undefined, + T3_TASK_CACHE_TELEMETRY: undefined, + T3_TASK_CACHE_HOST: undefined, + VITE_CACHE_PATH: undefined, + ACTIONS_ID_TOKEN_REQUEST_URL: undefined, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: undefined, + NODE_OPTIONS: `--import=${new URL("./preload.mjs", import.meta.url).href}`, + VP_REMOTE_CACHE: undefined, + VP_REMOTE_CACHE_URL: undefined, + }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code) => { + firstExit(); + if (code !== 0 && task !== "fail") reject(new Error(stderr)); + else resolve(code); + }); + }); + expect(await run("build")).toBe(0); + await report(1); + expect(await run("--last-details")).toBe(0); + expect( + JSON.parse(await NodeFSP.readFile(NodePath.join(root, "environment.json"), "utf8")), + ).toMatchObject({ url: null }); + expect(firstDisconnected).toBe(false); + expect(await run("build")).toBe(0); + await report(2); + expect(await run("fail")).toBe(7); + await report(3); + const secondWorktree = NodePath.join(root, "second-worktree"); + await NodeFSP.mkdir(secondWorktree); + for (const file of ["package.json", ".task-cache.json", "vite.config.ts", "build.cjs"]) + await NodeFSP.copyFile(NodePath.join(root, file), NodePath.join(secondWorktree, file)); + expect(await run("build", secondWorktree)).toBe(0); + await report(4); + expect(await run("build", secondWorktree, true)).toBe(0); + expect(completedReports).toBe(5); + expect(await NodeFSP.readFile(entrypoint, "utf8")).toBe(originalEntrypoint); + expect(await run("nested", root, false, "--no-cache")).toBe(0); + expect(await run("build", nested)).toBe(0); + expect(reports).toHaveLength(5); + expect( + reports.every( + (report) => + report.authorization === `Basic ${Buffer.from("maria:secret").toString("base64")}`, + ), + ).toBe(true); + const events = reports.map((report) => JSON.parse(report.body)); + expect(events[0].tasks[0].source).toBe("executed"); + expect(events[1].tasks[0].source).toBe("local"); + expect(events[3].tasks[0].source).toBe("executed"); + expect(new Set(events.map((event) => event.id)).size).toBe(5); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await NodeFSP.rm(root, { recursive: true, force: true }); + await NodeFSP.rm(unrelated, { recursive: true, force: true }); + } +}); + +it("rejects invalid setup credentials before installing anything and never prints the token", async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-setup-")); + const token = "private-credential-that-must-not-appear-in-output"; + const server = NodeHttp.createServer((_request, response) => response.writeHead(401).end()); + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing HTTP address"); + const result = await new Promise<{ code: number | null; output: string }>((resolve, reject) => { + const child = NodeChildProcess.spawn( + process.execPath, + [NodePath.join(import.meta.dirname, "../scripts/install.ts"), "--non-interactive"], + { + env: { + ...process.env, + HOME: home, + T3_CACHE_SETUP_URL: `http://127.0.0.1:${address.port}/projects/test`, + T3_CACHE_SETUP_NAME: "maria", + T3_CACHE_SETUP_TOKEN: token, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.once("error", reject); + child.once("close", (code) => resolve({ code, output })); + }); + expect(result.code).not.toBe(0); + expect(result.output).toContain("Credential verification failed (401)"); + expect(result.output).not.toContain(token); + expect(await NodeFSP.readdir(home)).toEqual([]); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await NodeFSP.rm(home, { recursive: true, force: true }); + } +}); + +it("does not start telemetry or create local state without cache credentials", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-unconfigured-")); + try { + await NodeFSP.writeFile( + NodePath.join(root, ".task-cache.json"), + JSON.stringify({ endpoint: "https://cache.invalid/projects/test" }), + ); + const hook = new URL("./runHook.ts", import.meta.url).href; + const output = await new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn( + process.execPath, + [ + "--input-type=module", + "-e", + `import start from ${JSON.stringify(hook)}; const finish = start({ root: process.cwd() }); if (finish !== undefined) process.exit(1);`, + ], + { + cwd: root, + env: { ...process.env, HOME: root, VP_REMOTE_CACHE_URL: "" }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.once("error", reject); + child.once("close", (code) => + code === 0 ? resolve(output) : reject(new Error(output || `Exited ${code}`)), + ); + }); + expect(output).toBe(""); + expect(await NodeFSP.readdir(root)).toEqual([".task-cache.json"]); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); + +it("installs without masking .profile and configures CI through bash", async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-login-")); + const server = NodeHttp.createServer((_request, response) => response.writeHead(204).end()); + const run = (args: string[], env: NodeJS.ProcessEnv) => + new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn(args[0]!, args.slice(1), { + env: { ...process.env, ...env, HOME: home, NODE_OPTIONS: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.once("error", reject); + child.once("close", (code) => (code === 0 ? resolve(output) : reject(new Error(output)))); + }); + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing HTTP address"); + await NodeFSP.writeFile(NodePath.join(home, ".profile"), "export EXISTING_PROFILE=preserved\n"); + await NodeFSP.mkdir(NodePath.join(home, ".config/t3-task-cache"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(home, ".config/t3-task-cache/credentials.json"), + "{partial", + ); + await run( + [ + process.execPath, + NodePath.join(import.meta.dirname, "../scripts/install.ts"), + "--non-interactive", + ], + { + T3_CACHE_SETUP_URL: `http://127.0.0.1:${address.port}/projects/test`, + T3_CACHE_SETUP_NAME: "maria", + T3_CACHE_SETUP_TOKEN: "test-credential-for-isolated-http-server", + }, + ); + expect(await NodeFSP.readdir(home)).not.toContain(".bash_profile"); + expect(await NodeFSP.readFile(NodePath.join(home, ".profile"), "utf8")).toContain( + "EXISTING_PROFILE=preserved", + ); + const githubEnv = NodePath.join(home, "github-env"); + await run([process.execPath, NodePath.join(import.meta.dirname, "../scripts/install-ci.ts")], { + T3_TASK_CACHE_READ_TOKEN: "read-only-fixture-token-01234567890123456789", + GITHUB_REPOSITORY: "pingdotgg/t3code", + GITHUB_ACTIONS: "true", + GITHUB_ENV: githubEnv, + RUNNER_TEMP: home, + }); + const values = await NodeFSP.readFile(githubEnv, "utf8"); + expect(values).not.toContain("\nNODE_OPTIONS="); + const bashEnv = values + .split("\n") + .find((line) => line.startsWith("BASH_ENV=")) + ?.slice(9); + const output = await run(["bash", "-c", 'printf "%s" "$NODE_OPTIONS"'], { BASH_ENV: bashEnv }); + expect(output).toContain("--import=file:"); + expect(output).toContain("/infra/task-cache/src/preload.mjs"); + await NodeFSP.rm(NodePath.join(home, ".local/share/t3-task-cache/preload.mjs")); + const missing = await run(["bash", "-c", `node -e 'console.log("missing preload is safe")'`], { + BASH_ENV: NodePath.join(home, ".config/t3-task-cache/env"), + }); + expect(missing).toContain("missing preload is safe"); + + await NodeFSP.writeFile( + NodePath.join(home, ".bash_profile"), + '. "$HOME/.config/t3-task-cache/env"\n', + ); + await run( + [ + process.execPath, + NodePath.join(import.meta.dirname, "../scripts/install.ts"), + "--uninstall", + ], + {}, + ); + expect(await NodeFSP.readdir(home)).not.toContain(".bash_profile"); + expect(await NodeFSP.readdir(NodePath.join(home, ".local/share/t3-task-cache"))).toEqual([ + "preload.mjs", + ]); + expect(await NodeFSP.readFile(NodePath.join(home, ".profile"), "utf8")).not.toContain( + "t3-task-cache/env", + ); + expect(await NodeFSP.readdir(NodePath.join(home, ".config/t3-task-cache"))).not.toContain( + "credentials.json", + ); + const remaining = await run( + [ + process.execPath, + "--import", + NodePath.join(home, ".local/share/t3-task-cache/preload.mjs"), + "-e", + 'console.log("node survives uninstall")', + ], + {}, + ); + expect(remaining).toContain("node survives uninstall"); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await NodeFSP.rm(home, { recursive: true, force: true }); + } +}); + +it("does not send an old endpoint's token after changing the URL interactively", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-endpoint-")); + const requests: string[] = []; + const server = NodeHttp.createServer((request, response) => { + requests.push(request.headers.authorization || ""); + response.writeHead(401).end(); + }); + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing HTTP address"); + const nested = NodePath.join(root, "nested"); + await NodeFSP.mkdir(nested); + await NodeFSP.writeFile( + NodePath.join(nested, "package.json"), + JSON.stringify({ name: "nested", scripts: { build: 'node -e "console.log(8)"' } }), + ); + await NodeFSP.writeFile(NodePath.join(nested, "pnpm-workspace.yaml"), "packages: []\n"); + const endpoint = `http://127.0.0.1:${address.port}/projects/test`; + const scripts = NodePath.join(root, "infra/task-cache/scripts"); + await NodeFSP.mkdir(scripts, { recursive: true }); + await NodeFSP.mkdir(NodePath.join(root, "infra/task-cache/src")); + await NodeFSP.writeFile(NodePath.join(root, "package.json"), '{"type":"module"}'); + await NodeFSP.writeFile(NodePath.join(root, ".task-cache.json"), JSON.stringify({ endpoint })); + await NodeFSP.copyFile( + NodePath.join(import.meta.dirname, "../scripts/install.ts"), + NodePath.join(scripts, "install.ts"), + ); + await NodeFSP.copyFile( + NodePath.join(import.meta.dirname, "nativeHook.js"), + NodePath.join(root, "infra/task-cache/src/nativeHook.js"), + ); + await NodeFSP.mkdir(NodePath.join(root, ".config/t3-task-cache"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(root, ".config/t3-task-cache/credentials.json"), + JSON.stringify({ + [endpoint]: { name: "maria", token: "different-saved-endpoint-credential" }, + }), + ); + const result = await new Promise<{ code: number | null; output: string }>((resolve, reject) => { + const child = NodeChildProcess.spawn( + process.execPath, + [NodePath.join(scripts, "install.ts")], + { + env: { + ...process.env, + HOME: root, + NODE_OPTIONS: "", + T3_CACHE_SETUP_URL: "https://old.example", + T3_CACHE_SETUP_NAME: "maria", + T3_CACHE_SETUP_TOKEN: "old-endpoint-token-must-not-be-forwarded", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + let output = ""; + let sentUrl = false; + let sentName = false; + child.stdout.on("data", (chunk) => { + output += chunk; + if (!sentUrl && output.includes("Cache URL")) { + sentUrl = true; + child.stdin.write(endpoint + "\n"); + } + if (!sentName && output.includes("Developer name")) { + sentName = true; + child.stdin.write("\n"); + } + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.once("error", reject); + child.once("close", (code) => resolve({ code, output })); + }); + expect(result.code).not.toBe(0); + expect(result.output).toContain("Use T3_CACHE_SETUP_TOKEN"); + expect(requests).toEqual([]); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); + +it("rejects non-loopback HTTP before transmitting a setup credential", async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-http-")); + try { + const output = await new Promise((resolve) => { + NodeChildProcess.execFile( + process.execPath, + [NodePath.join(import.meta.dirname, "../scripts/install.ts"), "--non-interactive"], + { + env: { + ...process.env, + HOME: home, + NODE_OPTIONS: "", + T3_CACHE_SETUP_URL: "http://cache.invalid/projects/test", + T3_CACHE_SETUP_NAME: "maria", + T3_CACHE_SETUP_TOKEN: "test-credential-that-must-not-be-transmitted", + }, + }, + (_error, stdout, stderr) => resolve(stdout + stderr), + ); + }); + expect(output).toContain("Use an HTTPS cache endpoint"); + expect(await NodeFSP.readdir(home)).toEqual([]); + } finally { + await NodeFSP.rm(home, { recursive: true, force: true }); + } +}); diff --git a/infra/task-cache/src/runHook.ts b/infra/task-cache/src/runHook.ts new file mode 100644 index 0000000000000..4f11fbbd4c26c --- /dev/null +++ b/infra/task-cache/src/runHook.ts @@ -0,0 +1,41 @@ +// @effect-diagnostics nodeBuiltinImport:off - runs inside Vite+ before project services start. +import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; +import { remoteCacheUrl } from "./remoteCache.ts"; +import { flushTelemetry, observeSummaries } from "./telemetry.ts"; + +export default function start({ root }: { readonly root: string }) { + const url = remoteCacheUrl(root); + if (!url) return; + const endpoint = new URL(url); + endpoint.username = ""; + endpoint.password = ""; + const stop = + process.env.T3_TASK_CACHE_TELEMETRY === "off" + ? undefined + : observeSummaries(root, endpoint.href.replace(/\/$/, ""), process.env); + const finish = async () => { + if (!stop) return; + stop(); + // GitHub can terminate detached processes as soon as the final step exits. + if (process.env.GITHUB_ACTIONS === "true") { + await flushTelemetry(); + return; + } + // A transient flusher drains the durable queue without delaying tasks or Ctrl-C. + const child = NodeChildProcess.spawn( + process.execPath, + [NodeURL.fileURLToPath(new URL("./flush.ts", import.meta.url))], + { + detached: true, + stdio: "ignore", + env: { ...process.env, NODE_OPTIONS: "" }, + }, + ); + child.on("error", () => {}); + child.unref(); + }; + return Object.assign(finish, { + remoteCache: process.env.VP_REMOTE_CACHE || process.env.VP_T3_TASK_CACHE_MODE || "read-write", + }); +} diff --git a/infra/task-cache/src/telemetry.test.ts b/infra/task-cache/src/telemetry.test.ts new file mode 100644 index 0000000000000..024aed74bed09 --- /dev/null +++ b/infra/task-cache/src/telemetry.test.ts @@ -0,0 +1,162 @@ +// @effect-diagnostics nodeBuiltinImport:off - launcher integration with filesystem and fetch. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { expect, it, vi } from "vite-plus/test"; +import { flushTelemetry, observeSummaries, summarize } from "./telemetry.ts"; + +const task = (result: unknown) => ({ + package_name: "@team/server", + task_name: "typecheck", + command: "SECRET", + cwd: "/private", + result, +}); + +it("counts only confirmed hits, preserves their tier, and never transmits command text", () => { + expect( + summarize({ + tasks: [ + task({ CacheHit: { source: "Remote", saved_duration_ms: 1200 } }), + task({ CacheHit: { source: "Local", saved_duration_ms: 500 } }), + task({ RestoreFailed: {} }), + task({ Spawned: {} }), + task({ CacheHit: { saved_duration_ms: 999 } }), + task({ CacheHit: { source: "Remote", saved_duration_ms: -1 } }), + ], + }), + ).toEqual([ + { package: "@team/server", task: "typecheck", source: "remote", savedMs: 1200 }, + { package: "@team/server", task: "typecheck", source: "local", savedMs: 500 }, + { package: "@team/server", task: "typecheck", source: "failed", savedMs: 0 }, + { package: "@team/server", task: "typecheck", source: "executed", savedMs: 0 }, + { package: "@team/server", task: "typecheck", source: "unspecified", savedMs: 999 }, + ]); +}); + +it("deduplicates shared summaries and retries delivery without credentials in queued records", async () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "cache-observer-")); + const home = NodePath.join(root, "home"); + const endpoint = "https://cache.example/projects/t3code"; + try { + const stopA = observeSummaries(root, endpoint, { T3_TASK_CACHE_HOST: "test-host" }, home); + const stopB = observeSummaries(root, endpoint, { T3_TASK_CACHE_HOST: "test-host" }, home); + const version = NodePath.join(root, "node_modules/.vite/task-cache/v18"); + NodeFS.mkdirSync(version); + NodeFS.writeFileSync( + NodePath.join(version, "last-summary.json"), + JSON.stringify({ + tasks: [task({ CacheHit: { source: "Remote", saved_duration_ms: 1200 } })], + }), + ); + stopA(); + stopB(); + const queue = NodePath.join(home, ".cache/t3-task-cache/telemetry"); + const files = NodeFS.readdirSync(queue); + expect(files).toHaveLength(1); + const serialized = NodeFS.readFileSync(NodePath.join(queue, files[0]!), "utf8"); + expect(serialized).not.toContain("SECRET"); + expect(JSON.parse(serialized).event.host).toBe("test-host"); + NodeFS.mkdirSync(NodePath.join(home, ".config/t3-task-cache"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(home, ".config/t3-task-cache/credentials.json"), + JSON.stringify({ [endpoint]: { name: "alice", token: "secret-token" } }), + ); + using request = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockImplementationOnce(async () => { + const late = JSON.parse(serialized); + late.event.id = "b".repeat(64); + NodeFS.writeFileSync(NodePath.join(queue, "late.json"), JSON.stringify(late)); + return new Response(null, { status: 204 }); + }) + .mockResolvedValue(new Response(null, { status: 204 })); + await flushTelemetry(home, {}); + expect(NodeFS.readdirSync(queue)).toEqual(files); + await flushTelemetry(home, {}); + expect(NodeFS.readdirSync(queue)).toEqual([]); + expect(request).toHaveBeenCalledTimes(3); + expect(request.mock.calls[0]![0].toString()).toBe(`${endpoint}/telemetry`); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("skips unauthenticated endpoints without consuming the delivery budget", async () => { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "cache-queue-")); + const endpoint = "https://cache.example/projects/t3code"; + try { + const queue = NodePath.join(home, ".cache/t3-task-cache/telemetry"); + NodeFS.mkdirSync(queue, { recursive: true }); + const skipped = Array.from({ length: 101 }, (_, i) => `a${i}.json`); + for (const file of skipped) + NodeFS.writeFileSync( + NodePath.join(queue, file), + JSON.stringify({ endpoint: "https://old.example/projects/t3code", event: { id: file } }), + ); + NodeFS.writeFileSync( + NodePath.join(queue, "b.json"), + JSON.stringify({ endpoint, event: { id: "current" } }), + ); + NodeFS.mkdirSync(NodePath.join(home, ".config/t3-task-cache"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(home, ".config/t3-task-cache/credentials.json"), + JSON.stringify({ [endpoint]: { name: "alice", token: "secret-token" } }), + ); + using request = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 204 })); + await flushTelemetry(home, {}); + expect(NodeFS.readdirSync(queue).sort()).toEqual(skipped.sort()); + expect(request).toHaveBeenCalledOnce(); + expect(request.mock.calls[0]![0].toString()).toBe(`${endpoint}/telemetry`); + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } +}); + +it("excludes tasks that explicitly disable caching from the hit-rate denominator", () => { + expect( + summarize({ + tasks: [ + task({ Spawned: { cache_status: "Disabled" } }), + task({ Spawned: { cache_status: { Miss: "changed" } } }), + ], + }), + ).toEqual([{ package: "@team/server", task: "typecheck", source: "executed", savedMs: 0 }]); +}); + +it("drops revoked credentials and continues delivering other queued endpoints", async () => { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "cache-revoked-")); + try { + const queue = NodePath.join(home, ".cache/t3-task-cache/telemetry"); + const state = NodePath.join(home, ".config/t3-task-cache"); + NodeFS.mkdirSync(queue, { recursive: true }); + NodeFS.mkdirSync(state, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(state, "credentials.json"), + JSON.stringify({ + "https://revoked.example": { name: "a", token: "old" }, + "https://current.example": { name: "a", token: "new" }, + }), + ); + for (const [file, endpoint] of [ + ["a.json", "https://revoked.example"], + ["b.json", "https://current.example"], + ]) + NodeFS.writeFileSync( + NodePath.join(queue, file!), + JSON.stringify({ endpoint, event: { id: file } }), + ); + using request = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(null, { status: 403 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + await flushTelemetry(home, {}); + expect(request).toHaveBeenCalledTimes(2); + expect(NodeFS.readdirSync(queue)).toEqual([]); + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/infra/task-cache/src/telemetry.ts b/infra/task-cache/src/telemetry.ts new file mode 100644 index 0000000000000..509167b6c023e --- /dev/null +++ b/infra/task-cache/src/telemetry.ts @@ -0,0 +1,256 @@ +/* oxlint-disable t3code/no-global-process-runtime -- This bootstrap runs before dependencies are installed. */ +// @effect-diagnostics globalDate:off globalFetch:off - bootstrap has no Effect dependencies. +// @effect-diagnostics nodeBuiltinImport:off - standalone collector installed outside the checkout. +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +export interface SummaryEvent { + id: string; + recordedAt: number; + host: string; + platform: string; + arch: string; + ci: boolean; + runId: string; + tasks: { + package: string; + task: string; + source: "local" | "remote" | "unspecified" | "executed" | "failed"; + savedMs: number; + }[]; +} + +export function summarize(value: unknown): SummaryEvent["tasks"] { + if (!value || typeof value !== "object" || !("tasks" in value) || !Array.isArray(value.tasks)) + return []; + return value.tasks.flatMap((task: unknown) => { + if ( + !task || + typeof task !== "object" || + !("package_name" in task) || + !("task_name" in task) || + !("result" in task) + ) + return []; + if ( + typeof task.package_name !== "string" || + typeof task.task_name !== "string" || + !task.result || + typeof task.result !== "object" + ) + return []; + let source: SummaryEvent["tasks"][number]["source"] = "executed"; + let savedMs = 0; + if ("CacheHit" in task.result) { + const hit = task.result.CacheHit; + if ( + !hit || + typeof hit !== "object" || + !("saved_duration_ms" in hit) || + typeof hit.saved_duration_ms !== "number" || + !Number.isSafeInteger(hit.saved_duration_ms) || + hit.saved_duration_ms < 0 + ) + return []; + if ("source" in hit && hit.source !== "Local" && hit.source !== "Remote") return []; + source = !("source" in hit) ? "unspecified" : hit.source === "Remote" ? "remote" : "local"; + savedMs = hit.saved_duration_ms; + } else if ("RestoreFailed" in task.result) source = "failed"; + else if (!("Spawned" in task.result)) return []; + else if ( + task.result.Spawned && + typeof task.result.Spawned === "object" && + "cache_status" in task.result.Spawned && + task.result.Spawned.cache_status === "Disabled" + ) + return []; + return [ + { + package: task.package_name.slice(0, 256), + task: task.task_name.slice(0, 256), + source, + savedMs, + }, + ]; + }); +} + +export function observeSummaries( + root: string, + endpoint: string, + env: NodeJS.ProcessEnv, + home = NodeOS.homedir(), +) { + const cache = env.VITE_CACHE_PATH || NodePath.join(root, "node_modules/.vite/task-cache"); + const queue = NodePath.join(home, ".cache/t3-task-cache/telemetry"); + let host = + env.T3_TASK_CACHE_HOST || + (env.GITHUB_ACTIONS === "true" + ? `${env.RUNNER_ENVIRONMENT || "github"}/${env.RUNNER_OS || NodeOS.platform()}/${env.RUNNER_ARCH || NodeOS.arch()}` + : NodeOS.hostname()); + try { + host = + env.T3_TASK_CACHE_HOST || + NodeFS.readFileSync(NodePath.join(home, ".agents/MACHINE"), "utf8").trim() || + host; + } catch { + /* Machine labels are optional outside the fleet. */ + } + const seen = new Set(); + const snapshot = (file: string, enqueue: boolean) => { + try { + const before = NodeFS.statSync(file, { bigint: true }); + const contents = NodeFS.readFileSync(file, "utf8"); + const after = NodeFS.statSync(file, { bigint: true }); + if (before.ino !== after.ino || before.mtimeNs !== after.mtimeNs) return; + // Nested/concurrent launchers see the same summary; stable ids prevent double counting. + const id = NodeCrypto.createHash("sha256") + .update(`${host}\0${root}\0${before.ino}\0${before.mtimeNs}\0${contents}`) + .digest("hex"); + if (seen.has(id)) return; + seen.add(id); + if (!enqueue) return; + const tasks = summarize(JSON.parse(contents)); + if (!tasks.length) return; + const event: SummaryEvent = { + id, + recordedAt: Number(before.mtimeNs / 1_000_000n), + host: host.slice(0, 256), + platform: NodeOS.platform(), + arch: NodeOS.arch(), + ci: env.GITHUB_ACTIONS === "true", + runId: env.GITHUB_RUN_ID || "", + tasks, + }; + NodeFS.mkdirSync(queue, { recursive: true, mode: 0o700 }); + const temporary = NodePath.join(queue, `${id}.${process.pid}.tmp`); + NodeFS.writeFileSync(temporary, JSON.stringify({ endpoint, event }), { mode: 0o600 }); + NodeFS.renameSync(temporary, NodePath.join(queue, `${id}.json`)); + } catch { + /* Telemetry must never prevent task execution. */ + } + }; + const scan = (enqueue: boolean) => { + for (const entry of NodeFS.readdirSync(cache, { withFileTypes: true })) { + if (entry.isDirectory()) + snapshot(NodePath.join(cache, entry.name, "last-summary.json"), enqueue); + } + }; + try { + NodeFS.mkdirSync(cache, { recursive: true }); + scan(false); + // Capture nested runs before the parent's atomic summary replaces them. + const watcher = NodeFS.watch(cache, { recursive: true }, (_event, file) => { + if (file?.endsWith("last-summary.json")) snapshot(NodePath.join(cache, file), true); + }); + watcher.on("error", () => watcher.close()); + return () => { + watcher.close(); + try { + scan(true); + } catch { + /* A task may remove its cache directory. */ + } + }; + } catch { + return () => {}; + } +} + +export async function flushTelemetry(home = NodeOS.homedir(), env = process.env) { + const queue = NodePath.join(home, ".cache/t3-task-cache/telemetry"); + const lock = NodePath.join(queue, "flush.lock"); + try { + NodeFS.mkdirSync(queue, { recursive: true, mode: 0o700 }); + try { + if (Date.now() - NodeFS.statSync(lock).mtimeMs > 60_000) NodeFS.rmdirSync(lock); + } catch { + /* No previous flusher. */ + } + NodeFS.mkdirSync(lock); + } catch { + return; + } + try { + const files = NodeFS.readdirSync(queue) + .filter((file) => file.endsWith(".json")) + .sort( + (a, b) => + NodeFS.statSync(NodePath.join(queue, a)).mtimeMs - + NodeFS.statSync(NodePath.join(queue, b)).mtimeMs, + ); + // Bound disk use while offline. Unsent events retry on the next invocation. + for (const file of files.slice(0, Math.max(0, files.length - 2000))) + NodeFS.rmSync(NodePath.join(queue, file), { force: true }); + const credentialsFile = NodePath.join(home, ".config/t3-task-cache/credentials.json"); + const credentials = NodeFS.existsSync(credentialsFile) + ? JSON.parse(NodeFS.readFileSync(credentialsFile, "utf8")) + : {}; + const deadline = Date.now() + 15_000; + const skipped = new Set(); + // Re-scan between sends so a concurrent command's final summary joins this flush. + for (let sent = 0; sent < 100 && Date.now() < deadline;) { + const file = NodeFS.readdirSync(queue) + .filter((entry) => entry.endsWith(".json") && !skipped.has(entry)) + .sort()[0]; + if (!file) break; + const path = NodePath.join(queue, file); + const { endpoint, event } = JSON.parse(NodeFS.readFileSync(path, "utf8")); + const url = new URL(endpoint); + const configured = env.VP_REMOTE_CACHE_URL ? new URL(env.VP_REMOTE_CACHE_URL) : undefined; + const configuredEndpoint = configured && new URL(configured.href); + if (configuredEndpoint) { + configuredEndpoint.username = ""; + configuredEndpoint.password = ""; + } + const credential = + configured?.username && configuredEndpoint?.href.replace(/\/$/, "") === endpoint + ? { + name: decodeURIComponent(configured.username), + token: decodeURIComponent(configured.password), + } + : credentials[endpoint]; + let authorization: string; + if ( + credential && + !( + env.GITHUB_ACTIONS === "true" && + env.ACTIONS_ID_TOKEN_REQUEST_URL && + env.ACTIONS_ID_TOKEN_REQUEST_TOKEN + ) + ) + authorization = `Basic ${Buffer.from(`${credential.name}:${credential.token}`).toString("base64")}`; + else if (env.ACTIONS_ID_TOKEN_REQUEST_URL && env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) { + const tokenUrl = new URL(env.ACTIONS_ID_TOKEN_REQUEST_URL); + tokenUrl.searchParams.set("audience", endpoint); + const response = await fetch(tokenUrl, { + headers: { Authorization: `Bearer ${env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` }, + signal: AbortSignal.timeout(3000), + }); + if (!response.ok) return; + const token = (await response.json()) as { value: string }; + authorization = `Bearer ${token.value}`; + } else { + skipped.add(file); + continue; + } + sent++; + url.pathname = `${url.pathname.replace(/\/$/, "")}/telemetry`; + const response = await fetch(url, { + method: "POST", + headers: { Authorization: authorization, "Content-Type": "application/json" }, + body: JSON.stringify(event), + signal: AbortSignal.timeout(3000), + }); + await response.body?.cancel(); + if (response.ok || [400, 401, 403, 413].includes(response.status)) NodeFS.unlinkSync(path); + else return; + } + } catch { + /* Leave unsent events for a later invocation. */ + } finally { + NodeFS.rmdirSync(lock); + } +} diff --git a/infra/task-cache/src/telemetryAuth.test.ts b/infra/task-cache/src/telemetryAuth.test.ts new file mode 100644 index 0000000000000..7b04a89079a73 --- /dev/null +++ b/infra/task-cache/src/telemetryAuth.test.ts @@ -0,0 +1,84 @@ +import { generateKeyPair, SignJWT } from "jose"; +import { expect, it, vi } from "vite-plus/test"; +import { authorizeCacheWrite, authorizeTelemetry } from "./telemetryAuth.ts"; +import { authorize, ISSUER } from "../vendor/vite-plus-remote-cache-cloudflare/src/auth.ts"; + +it("accepts signed stacked PR telemetry without granting cache writes or trusting another repository", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(1_000_000); + try { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const scope = { + scope_id: "t3code", + endpoint: "https://cache.example/projects/t3code", + repository_id: "123", + repository_owner_id: "456", + branch: "refs/heads/main", + policy_version: 1, + writes_enabled: 0, + pull_requests: 0, + }; + const token = async (repository_id: string, audience = scope.endpoint) => + new SignJWT({ + repository_id, + repository_owner_id: "456", + repository_visibility: "public", + event_name: "pull_request", + ref: "refs/pull/42/merge", + ref_type: "branch", + base_ref: "feature/parent", + actor: "maria", + }) + .setProtectedHeader({ alg: "RS256", kid: "test" }) + .setIssuer(ISSUER) + .setAudience(audience) + .setIssuedAt(1000) + .setNotBefore(1000) + .setExpirationTime(1600) + .sign(privateKey); + const request = (token: string) => + new Request(scope.endpoint, { headers: { Authorization: `Bearer ${token}` } }); + const keys = async () => publicKey; + const valid = request(await token("123")); + const allowed = { + ...scope, + writes_enabled: 1, + pull_requests: 1, + branch: "refs/heads/feature/parent", + }; + expect( + await authorizeCacheWrite( + request(await token("123", "https://ci-reader:secret@cache.example/projects/t3code")), + allowed, + keys, + ), + ).toMatchObject({ repository_id: "123" }); + await expect( + authorizeCacheWrite( + request(await token("123", "https://ci-reader:secret@other.example/projects/t3code")), + allowed, + keys, + ), + ).rejects.toMatchObject({ status: 403 }); + expect(await authorizeTelemetry(valid, scope, keys)).toBe("github/maria"); + await expect( + authorizeCacheWrite(valid, { ...allowed, pull_requests: 0 }, keys), + ).rejects.toMatchObject({ status: 403 }); + await expect( + authorizeCacheWrite(valid, { ...allowed, branch: "refs/heads/main" }, keys), + ).rejects.toMatchObject({ status: 403 }); + await expect(authorize(valid, scope, keys)).rejects.toMatchObject({ status: 403 }); + await expect(authorizeCacheWrite(valid, scope, keys)).rejects.toMatchObject({ status: 403 }); + expect(await authorizeCacheWrite(valid, allowed, keys)).toMatchObject({ + repository_id: "123", + }); + await expect( + authorizeCacheWrite(request(await token("another-repo")), allowed, keys), + ).rejects.toMatchObject({ status: 403 }); + await expect( + authorizeTelemetry(request(await token("another-repo")), scope, keys), + ).rejects.toMatchObject({ status: 403 }); + } finally { + vi.useRealTimers(); + } +}); diff --git a/infra/task-cache/src/telemetryAuth.ts b/infra/task-cache/src/telemetryAuth.ts new file mode 100644 index 0000000000000..37a59e2318177 --- /dev/null +++ b/infra/task-cache/src/telemetryAuth.ts @@ -0,0 +1,44 @@ +import { decodeJwt, type JWTVerifyGetKey } from "jose"; +import { authorize } from "../vendor/vite-plus-remote-cache-cloudflare/src/auth.ts"; +import type { Scope } from "../vendor/vite-plus-remote-cache-cloudflare/src/database.ts"; +import { HttpError } from "../vendor/vite-plus-remote-cache-cloudflare/src/errors.ts"; + +function audienceScope(request: Request, scope: Scope, telemetry = false) { + const authorization = request.headers.get("authorization"); + if (!authorization || authorization.length > 16 * 1024) throw new HttpError(401, "invalid_token"); + let payload; + try { + payload = decodeJwt(authorization.slice(7)); + } catch { + throw new HttpError(401, "invalid_token"); + } + // The decoded base only selects policy; authorize verifies all claims and the signature. + const branch = + telemetry && payload.event_name === "pull_request" && typeof payload.base_ref === "string" + ? `refs/heads/${payload.base_ref}` + : scope.branch; + let endpoint = scope.endpoint; + if (typeof payload.aud === "string") { + try { + const audience = new URL(payload.aud); + audience.username = ""; + audience.password = ""; + // Vite+ uses the configured URL, including read credentials, as its OIDC audience. + if (audience.href.replace(/\/$/, "") === scope.endpoint.replace(/\/$/, "")) + endpoint = payload.aud; + } catch { + // The verified authorization below rejects malformed and foreign audiences. + } + } + return { ...scope, branch, endpoint, pull_requests: telemetry ? 1 : scope.pull_requests }; +} + +export async function authorizeCacheWrite(request: Request, scope: Scope, keys: JWTVerifyGetKey) { + return authorize(request, audienceScope(request, scope), keys); +} + +export async function authorizeTelemetry(request: Request, scope: Scope, keys: JWTVerifyGetKey) { + await authorize(request, audienceScope(request, { ...scope, writes_enabled: 1 }, true), keys); + const payload = decodeJwt(request.headers.get("authorization")!.slice(7)); + return `github/${typeof payload.actor === "string" ? payload.actor : "unknown"}`; +} diff --git a/infra/task-cache/src/worker.ts b/infra/task-cache/src/worker.ts new file mode 100644 index 0000000000000..5fc8eac15c3a4 --- /dev/null +++ b/infra/task-cache/src/worker.ts @@ -0,0 +1,98 @@ +import * as Axiom from "alchemy/Axiom"; +import { Observability } from "./observability.ts"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import * as Admission from "./Admission.ts"; +import * as Cache from "./Cache.ts"; +import * as Savings from "./Savings.ts"; +import type { ProtocolBindings } from "./protocolTypes.ts"; +import * as Resources from "./resources.ts"; + +export class Worker extends Cloudflare.Worker()("Cache") {} + +export const layer = Worker.make( + Resources.cacheName.pipe( + Effect.map((name) => ({ + name, + main: import.meta.filename, + compatibility: { date: "2026-09-11", flags: ["nodejs_compat"] }, + })), + ), + Effect.gen(function* () { + const savings = yield* Cloudflare.D1.QueryDatabase(yield* Resources.Savings); + const database = yield* Cloudflare.D1.QueryDatabase(yield* Resources.Index); + const artifacts = yield* Cloudflare.R2.ReadWriteBucket(yield* Resources.Artifacts); + const developerKeys = yield* Config.Redacted("DEVELOPER_KEYS"); + const readerKeys = yield* Config.Redacted("READER_KEYS"); + const reads = yield* Cloudflare.RateLimit("READ_LIMITER", { + namespaceId: "1001", + simple: { limit: 600, period: 60 }, + }); + const writes = yield* Cloudflare.RateLimit("STORE_LIMITER", { + namespaceId: "1002", + simple: { limit: 600, period: 60 }, + }); + const bindings = Effect.gen(function* () { + return Cache.Bindings.of({ + INDEX: yield* database.raw, + ARTIFACTS: (yield* artifacts.raw) as unknown as ProtocolBindings["ARTIFACTS"], + GC_BATCH_SIZE: "16", + developerKeys: Redacted.value(developerKeys), + readerKeys: Redacted.value(readerKeys), + readLimit: yield* reads.raw, + storeLimit: yield* writes.raw, + }); + }); + const admission = yield* Admission.Admission.pipe(Effect.provide(Admission.layer)); + const savingsLayer = Savings.layer.pipe( + Layer.provide(Layer.effect(Savings.Database, savings.raw)), + ); + const runtime = Cache.layer.pipe( + Layer.provide([ + Layer.succeed(Admission.Admission, admission), + Layer.effect(Cache.Bindings, bindings), + savingsLayer, + ]), + ); + yield* Cloudflare.Workers.cron("*/5 * * * *", () => + Cache.Cache.pipe( + Effect.flatMap((cache) => cache.cleanup), + Effect.provide(runtime), + Effect.orDie, + ), + ); + yield* Cloudflare.Workers.cron("0 * * * *", () => + Savings.Savings.pipe( + Effect.flatMap((savings) => savings.cleanup.pipe(Effect.andThen(savings.emitTotals()))), + Effect.provide(savingsLayer), + Effect.orDie, + ), + ); + return { + fetch: Effect.gen(function* () { + const request = yield* Cloudflare.Workers.Request; + const context = yield* Cloudflare.Workers.WorkerExecutionContext; + const cache = yield* Cache.Cache; + return HttpServerResponse.fromWeb(yield* cache.handle(request, context.raw)); + }).pipe(Effect.provide(runtime)), + }; + }).pipe( + Effect.provide([ + Cloudflare.D1.QueryDatabaseBinding, + Cloudflare.R2.ReadWriteBucketBinding, + Cloudflare.Workers.RateLimitBinding, + Cloudflare.Workers.CronEventSourceLive, + Layer.unwrap( + Effect.map(Observability, ({ traces, token }) => + Axiom.Telemetry({ serviceName: "t3-task-cache", traces, token }), + ), + ), + ]), + ), +); +// Alchemy's generated Worker entry imports the runtime layer as the default export. +export default layer; diff --git a/infra/task-cache/tsconfig.json b/infra/task-cache/tsconfig.json new file mode 100644 index 0000000000000..85e659902b405 --- /dev/null +++ b/infra/task-cache/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "lib": ["ESNext", "DOM"], + "types": ["node"], + "erasableSyntaxOnly": false + }, + "include": ["alchemy.run.ts", "src/**/*.ts", "scripts/**/*.ts", "types/**/*.d.ts"] +} diff --git a/infra/task-cache/types/upstream.d.ts b/infra/task-cache/types/upstream.d.ts new file mode 100644 index 0000000000000..9a9a548e46e64 --- /dev/null +++ b/infra/task-cache/types/upstream.d.ts @@ -0,0 +1,12 @@ +// The source-only upstream package expects Wrangler-generated globals but does not ship them. +import type * as Cloudflare from "@cloudflare/workers-types"; +import type { ProtocolBindings } from "../src/protocolTypes.ts"; + +declare global { + interface Env extends ProtocolBindings {} + type D1Database = Cloudflare.D1Database; + type D1Result = Cloudflare.D1Result; + type R2MultipartUpload = Cloudflare.R2MultipartUpload; + type R2UploadedPart = Cloudflare.R2UploadedPart; + type ExecutionContext = Cloudflare.ExecutionContext; +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/LICENSE b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/LICENSE new file mode 100644 index 0000000000000..cf067b10e0dcf --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026-present, VoidZero Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/README.md b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/README.md new file mode 100644 index 0000000000000..ff34390cf62f4 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/README.md @@ -0,0 +1 @@ +Vendored from [vite-plus-remote-cache-cloudflare](https://github.com/voidzero-dev/vite-plus-remote-cache-cloudflare/tree/5b06c2c10d84ea441735ba07c46f6d1b7cb2ff3b) at commit `5b06c2c10d84ea441735ba07c46f6d1b7cb2ff3b`. Source and migrations match upstream except for Effect diagnostic suppression comments; the package manifest omits upstream development tooling. MIT license is included. This avoids requiring GitHub downloads for contributor installs. diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0001_cache.sql b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0001_cache.sql new file mode 100644 index 0000000000000..31f73fa03b583 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0001_cache.sql @@ -0,0 +1,160 @@ +-- Primary D1 owns policy and accounting. Back up policies separately from cache data. +CREATE TABLE deployment ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + writes_enabled INTEGER NOT NULL DEFAULT 1 CHECK (writes_enabled IN (0, 1)), + byte_limit INTEGER NOT NULL DEFAULT 8000000000 CHECK (byte_limit > 0), + entry_limit INTEGER NOT NULL DEFAULT 20000 CHECK (entry_limit > 0), + association_limit INTEGER NOT NULL DEFAULT 20000 CHECK (association_limit > 0), + retention_high_water_seconds INTEGER NOT NULL DEFAULT 604800, + charged_bytes INTEGER NOT NULL DEFAULT 0 CHECK (charged_bytes >= 0), + entry_count INTEGER NOT NULL DEFAULT 0 CHECK (entry_count >= 0), + association_count INTEGER NOT NULL DEFAULT 0 CHECK (association_count >= 0) +); +INSERT INTO deployment (id) VALUES (1); +CREATE TABLE scopes ( + scope_id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL UNIQUE, + repository TEXT NOT NULL, + repository_id TEXT NOT NULL, + repository_owner_id TEXT NOT NULL, + branch TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + writes_enabled INTEGER NOT NULL DEFAULT 1 CHECK (writes_enabled IN (0, 1)), + policy_version INTEGER NOT NULL DEFAULT 1, + retention_seconds INTEGER NOT NULL DEFAULT 604800 CHECK (retention_seconds > 0), + byte_limit INTEGER NOT NULL DEFAULT 8000000000 CHECK (byte_limit > 0), + entry_limit INTEGER NOT NULL DEFAULT 20000 CHECK (entry_limit > 0), + association_limit INTEGER NOT NULL DEFAULT 20000 CHECK (association_limit > 0), + charged_bytes INTEGER NOT NULL DEFAULT 0 CHECK (charged_bytes >= 0), + entry_count INTEGER NOT NULL DEFAULT 0 CHECK (entry_count >= 0), + association_count INTEGER NOT NULL DEFAULT 0 CHECK (association_count >= 0) +); +CREATE TABLE generations ( + generation_id TEXT PRIMARY KEY, + scope_id TEXT NOT NULL REFERENCES scopes(scope_id), + state TEXT NOT NULL CHECK (state IN ('uploading', 'ready', 'retired', 'deleting')), + policy_version INTEGER NOT NULL, + token_exp INTEGER NOT NULL, + lease_until INTEGER NOT NULL, + gc_after INTEGER NOT NULL, + gc_claim TEXT, + key BLOB, + secondary_key BLOB, + value_object TEXT NOT NULL UNIQUE, + blob_object TEXT NOT NULL UNIQUE, + blob_id TEXT, + upload_id TEXT, + value_size INTEGER NOT NULL DEFAULT 0 CHECK (value_size >= 0), + blob_size INTEGER NOT NULL DEFAULT 0 CHECK (blob_size >= 0), + charged_bytes INTEGER NOT NULL CHECK (charged_bytes >= 0), + expires_at INTEGER, + retired_at INTEGER, + UNIQUE (scope_id, generation_id) +); +CREATE UNIQUE INDEX generations_blob ON generations(scope_id, blob_id) WHERE blob_id IS NOT NULL; +CREATE INDEX generations_gc ON generations(gc_after); +CREATE TABLE entries ( + scope_id TEXT NOT NULL REFERENCES scopes(scope_id), + key BLOB NOT NULL CHECK (typeof(key) = 'blob'), + generation_id TEXT NOT NULL, + PRIMARY KEY (scope_id, key), + FOREIGN KEY (scope_id, generation_id) REFERENCES generations(scope_id, generation_id) ON DELETE CASCADE +) WITHOUT ROWID; +CREATE INDEX entries_generation ON entries(generation_id); +CREATE TABLE associations ( + scope_id TEXT NOT NULL REFERENCES scopes(scope_id), + secondary_key BLOB NOT NULL CHECK (typeof(secondary_key) = 'blob'), + target_key BLOB NOT NULL CHECK (typeof(target_key) = 'blob'), + PRIMARY KEY (scope_id, secondary_key) +) WITHOUT ROWID; +CREATE INDEX associations_target ON associations(scope_id, target_key); +CREATE TABLE maintenance (id INTEGER PRIMARY KEY CHECK (id = 1), scope_id TEXT NOT NULL, secondary_key BLOB NOT NULL); +INSERT INTO maintenance VALUES (1, '', X''); + +-- Parenthesize CASE expressions so the D1 REST parser preserves each trigger body. +-- Lifecycle rules must never shorten the life of an already published generation. +CREATE TRIGGER initial_retention AFTER INSERT ON scopes BEGIN + UPDATE deployment SET retention_high_water_seconds = max(retention_high_water_seconds, NEW.retention_seconds); +END; +CREATE TRIGGER increased_retention AFTER UPDATE OF retention_seconds ON scopes BEGIN + UPDATE deployment SET retention_high_water_seconds = max(retention_high_water_seconds, NEW.retention_seconds); +END; +CREATE TRIGGER scope_limit BEFORE INSERT ON scopes +WHEN NOT EXISTS (SELECT 1 FROM scopes WHERE scope_id = NEW.scope_id) +BEGIN + SELECT (CASE WHEN (SELECT count(*) FROM scopes) >= 100 THEN RAISE(ABORT, 'cache_scope_limit') END); +END; + +-- Direct policy edits must invalidate uploads that captured the previous policy. +CREATE TRIGGER changed_policy AFTER UPDATE OF endpoint, repository_id, repository_owner_id, branch, + enabled, writes_enabled, retention_seconds, byte_limit, entry_limit, association_limit ON scopes +WHEN NEW.policy_version = OLD.policy_version +BEGIN + UPDATE scopes SET policy_version = OLD.policy_version + 1 WHERE scope_id = NEW.scope_id; +END; + +CREATE TRIGGER reserve_capacity BEFORE INSERT ON generations BEGIN + SELECT (CASE WHEN NOT EXISTS ( + SELECT 1 FROM scopes s, deployment d WHERE s.scope_id = NEW.scope_id + AND s.enabled = 1 AND s.writes_enabled = 1 AND d.enabled = 1 AND d.writes_enabled = 1 + AND s.policy_version = NEW.policy_version AND NEW.token_exp > unixepoch() + AND s.charged_bytes + NEW.charged_bytes <= s.byte_limit + AND d.charged_bytes + NEW.charged_bytes <= d.byte_limit + ) THEN RAISE(ABORT, 'cache_admission_denied') END); +END; +CREATE TRIGGER charge_generation AFTER INSERT ON generations BEGIN + UPDATE scopes SET charged_bytes = charged_bytes + NEW.charged_bytes WHERE scope_id = NEW.scope_id; + UPDATE deployment SET charged_bytes = charged_bytes + NEW.charged_bytes; +END; +CREATE TRIGGER adjust_generation AFTER UPDATE OF charged_bytes ON generations BEGIN + UPDATE scopes SET charged_bytes = charged_bytes + NEW.charged_bytes - OLD.charged_bytes WHERE scope_id = NEW.scope_id; + UPDATE deployment SET charged_bytes = charged_bytes + NEW.charged_bytes - OLD.charged_bytes; +END; +CREATE TRIGGER release_generation AFTER DELETE ON generations BEGIN + UPDATE scopes SET charged_bytes = charged_bytes - OLD.charged_bytes WHERE scope_id = OLD.scope_id; + UPDATE deployment SET charged_bytes = charged_bytes - OLD.charged_bytes; +END; + +CREATE TRIGGER count_entry BEFORE INSERT ON entries +WHEN NOT EXISTS (SELECT 1 FROM entries WHERE scope_id = NEW.scope_id AND key = NEW.key) +BEGIN + SELECT (CASE WHEN EXISTS ( + SELECT 1 FROM scopes s, deployment d WHERE s.scope_id = NEW.scope_id + AND (s.entry_count >= s.entry_limit OR d.entry_count >= d.entry_limit) + ) THEN RAISE(ABORT, 'cache_entry_limit') END); + UPDATE scopes SET entry_count = entry_count + 1 WHERE scope_id = NEW.scope_id; + UPDATE deployment SET entry_count = entry_count + 1; +END; +CREATE TRIGGER uncount_entry AFTER DELETE ON entries BEGIN + UPDATE scopes SET entry_count = entry_count - 1 WHERE scope_id = OLD.scope_id; + UPDATE deployment SET entry_count = entry_count - 1; +END; +CREATE TRIGGER count_association BEFORE INSERT ON associations +WHEN NOT EXISTS (SELECT 1 FROM associations WHERE scope_id = NEW.scope_id AND secondary_key = NEW.secondary_key) +BEGIN + SELECT (CASE WHEN EXISTS ( + SELECT 1 FROM scopes s, deployment d WHERE s.scope_id = NEW.scope_id + AND (s.association_count >= s.association_limit OR d.association_count >= d.association_limit) + ) THEN RAISE(ABORT, 'cache_association_limit') END); + UPDATE scopes SET association_count = association_count + 1 WHERE scope_id = NEW.scope_id; + UPDATE deployment SET association_count = association_count + 1; +END; +CREATE TRIGGER uncount_association AFTER DELETE ON associations BEGIN + UPDATE scopes SET association_count = association_count - 1 WHERE scope_id = OLD.scope_id; + UPDATE deployment SET association_count = association_count - 1; +END; + +-- All publication mutations inherit the guarded state change's transaction. +-- A zero-row guard fires no trigger. A quota failure rolls every mutation back. +CREATE TRIGGER publish_generation AFTER UPDATE OF state ON generations +WHEN OLD.state = 'uploading' AND NEW.state = 'ready' +BEGIN + UPDATE generations SET state = 'retired', retired_at = unixepoch(), + gc_after = (CASE WHEN expires_at > unixepoch() THEN unixepoch() + 600 ELSE unixepoch() END) + WHERE generation_id = (SELECT generation_id FROM entries WHERE scope_id = NEW.scope_id AND key = NEW.key) AND state = 'ready'; + INSERT INTO entries (scope_id, key, generation_id) VALUES (NEW.scope_id, NEW.key, NEW.generation_id) + ON CONFLICT (scope_id, key) DO UPDATE SET generation_id = excluded.generation_id; + INSERT INTO associations (scope_id, secondary_key, target_key) VALUES (NEW.scope_id, NEW.secondary_key, NEW.key) + ON CONFLICT (scope_id, secondary_key) DO UPDATE SET target_key = excluded.target_key; +END; diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0002_pull_requests.sql b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0002_pull_requests.sql new file mode 100644 index 0000000000000..86418a2787838 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/migrations/0002_pull_requests.sql @@ -0,0 +1,12 @@ +-- Per-namespace opt-in for uploads from pull_request runs whose base is the registered branch. +-- Off by default: such uploads run unreviewed PR code, and default-branch builds restore their entries. +ALTER TABLE scopes ADD COLUMN pull_requests INTEGER NOT NULL DEFAULT 0 CHECK (pull_requests IN (0, 1)); + +-- Turning the opt-in off must also invalidate uploads that captured the previous policy. +DROP TRIGGER changed_policy; +CREATE TRIGGER changed_policy AFTER UPDATE OF endpoint, repository_id, repository_owner_id, branch, + enabled, writes_enabled, pull_requests, retention_seconds, byte_limit, entry_limit, association_limit ON scopes +WHEN NEW.policy_version = OLD.policy_version +BEGIN + UPDATE scopes SET policy_version = OLD.policy_version + 1 WHERE scope_id = NEW.scope_id; +END; diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/package.json b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/package.json new file mode 100644 index 0000000000000..3f6f180f87cfb --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/package.json @@ -0,0 +1,10 @@ +{ + "name": "@voidzero-dev/vite-plus-remote-cache-cloudflare", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "dependencies": { + "jose": "catalog:" + } +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/admission.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/admission.ts new file mode 100644 index 0000000000000..0de93ba47bed3 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/admission.ts @@ -0,0 +1,17 @@ +import { HttpError } from './errors.ts'; + +// Isolate-wide resource counters contain no request data or I/O promises. Rate +// limiting controls traffic; these counters additionally bound simultaneous buffers. +export class Admission { + private stores = 0; + private reads = 0; + acquire(store: boolean): () => void { + if (store ? this.stores >= 2 : this.reads >= 4) throw new HttpError(503, 'concurrency_limit'); + if (store) this.stores++; + else this.reads++; + return () => { + if (store) this.stores--; + else this.reads--; + }; + } +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/auth.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/auth.ts new file mode 100644 index 0000000000000..1e12addf0b64c --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/auth.ts @@ -0,0 +1,166 @@ +// @effect-diagnostics globalDate:off globalFetch:off - vendored upstream protocol, not an Effect service. +import { + createRemoteJWKSet, + customFetch, + decodeProtectedHeader, + errors, + jwtVerify, + type JWTVerifyGetKey, + type JWTPayload, +} from 'jose'; +import { HttpError, unavailable } from './errors.ts'; +import { Deadline, readBody } from './streams.ts'; +import type { Scope } from './database.ts'; + +export const ISSUER = 'https://token.actions.githubusercontent.com'; +export const JWKS_URL = `${ISSUER}/.well-known/jwks`; + +export function githubKeys(): JWTVerifyGetKey { + // Only reusable public-key cache state lives across requests. Failed refreshes + // also have a cooldown; jose's own cooldown starts only after a successful fetch. + let lastAttempt = -Infinity; + return createRemoteJWKSet(new URL(JWKS_URL), { + timeoutDuration: 5000, + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000, + [customFetch]: async (url, options) => { + if (Date.now() - lastAttempt < 30_000) unavailable(); + lastAttempt = Date.now(); + const deadline = new Deadline(5000, options.signal); + try { + const response = await deadline.run(fetch(url, options)); + if (response.status !== 200) { + void response.body?.cancel(); + unavailable(); + } + const bytes = await readBody(response.body, 64 * 1024, deadline); + return new Response(bytes, { headers: { 'Content-Type': 'application/json' } }); + } catch { + unavailable(); + } finally { + deadline.dispose(); + } + }, + }); +} + +export interface WriteIdentity { + exp: number; + repository_id: string; + workflow_ref?: string; + run_id?: string; + run_attempt?: string; + sha?: string; + event_name?: string; + head_ref?: string; +} + +export async function authorize( + request: Request, + scope: Scope, + keys: JWTVerifyGetKey, +): Promise { + const authorization = request.headers.get('Authorization'); + if ( + !authorization || + authorization.length > 16 * 1024 || + !/^Bearer [A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/i.test(authorization) + ) { + throw new HttpError(401, 'invalid_token'); + } + const token = authorization.slice(7); + let payload: JWTPayload; + try { + const header = decodeProtectedHeader(token); + if ( + header.alg !== 'RS256' || + typeof header.kid !== 'string' || + header.kid.length > 256 || + header.jku || + header.x5u || + header.jwk + ) { + throw new HttpError(401, 'invalid_token'); + } + ({ payload } = await jwtVerify(token, keys, { + algorithms: ['RS256'], + issuer: ISSUER, + requiredClaims: ['exp', 'nbf', 'iat'], + clockTolerance: 30, + maxTokenAge: '15 minutes', + })); + } catch (error) { + if (error instanceof HttpError) throw error; + if ( + error instanceof errors.JWTExpired || + error instanceof errors.JWTClaimValidationFailed || + error instanceof errors.JWSSignatureVerificationFailed || + error instanceof errors.JWSInvalid || + error instanceof errors.JWTInvalid || + error instanceof errors.JOSENotSupported || + error instanceof errors.JWKSNoMatchingKey || + error instanceof errors.JOSEAlgNotAllowed + ) { + throw new HttpError(401, 'invalid_token'); + } + unavailable(); + } + const now = Date.now() / 1000; + const { exp, nbf, iat } = payload; + if ( + typeof exp !== 'number' || + typeof nbf !== 'number' || + typeof iat !== 'number' || + !Number.isSafeInteger(exp) || + !Number.isSafeInteger(nbf) || + !Number.isSafeInteger(iat) || + exp <= now || + nbf > now + 30 || + iat > now + 30 || + iat < now - 930 || + nbf > exp || + iat >= exp || + exp - iat > 900 + ) { + throw new HttpError(401, 'invalid_token'); + } + // A `push` must update the registered branch. A namespace that opts in also accepts + // `pull_request` runs whose PR targets that branch. For those, GitHub signs `ref` + // `refs/pull//merge`, `ref_type` `branch`, and the target branch's short name (`main`) + // in `base_ref`. The `closed` run of a merged PR has the base branch in `ref` and is denied. + // Fork PR runs carry the base repository's IDs too; only GitHub withholding `id-token: write` + // from them keeps them out. + const event = + payload.event_name === 'push' + ? payload.ref === scope.branch + : payload.event_name === 'pull_request' && + scope.pull_requests === 1 && + typeof payload.ref === 'string' && + /^refs\/pull\/[1-9][0-9]*\/merge$/.test(payload.ref) && + typeof payload.base_ref === 'string' && + `refs/heads/${payload.base_ref}` === scope.branch; + if ( + !scope.writes_enabled || + payload.aud !== scope.endpoint || + payload.repository_id !== scope.repository_id || + payload.repository_owner_id !== scope.repository_owner_id || + payload.repository_visibility !== 'public' || + payload.ref_type !== 'branch' || + !event + ) { + throw new HttpError(403, 'write_policy'); + } + const identity: WriteIdentity = { exp, repository_id: scope.repository_id }; + for (const name of [ + 'workflow_ref', + 'run_id', + 'run_attempt', + 'sha', + 'event_name', + 'head_ref', + ] as const) { + const value = payload[name]; + if (typeof value === 'string' && value && value.length <= 512) identity[name] = value; + } + return identity; +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/cbor.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/cbor.ts new file mode 100644 index 0000000000000..08510e39453bf --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/cbor.ts @@ -0,0 +1,151 @@ +import { badRequest, tooLarge } from './errors.ts'; +import type { Limits } from './limits.ts'; + +export interface FetchRequest { + key: Uint8Array; + secondary_key: Uint8Array; +} + +export interface StoreMetadata extends FetchRequest { + value: Uint8Array; +} + +// This codec only decodes the protocol envelope. Opaque bytes are never decoded. +// The accepted containers are one map and (possibly chunked) strings: depth <= 2. +class Decoder { + offset = 0; + constructor(private data: Uint8Array) {} + byte(): number { + return this.data[this.offset++] ?? badRequest(); + } + head(major: number): number | null { + const first = this.byte(); + if (first >> 5 !== major) badRequest(); + const info = first & 31; + if (info < 24) return info; + if (info === 31) return null; + if (info > 27) badRequest(); + let length = 0; + for (let i = 0; i < 2 ** (info - 24); i++) length = length * 256 + this.byte(); + if (!Number.isSafeInteger(length)) tooLarge(); + return length; + } + string(major: number, limit: number): Uint8Array { + const length = this.head(major); + if (length !== null) return this.take(length, limit); + const chunks: Uint8Array[] = []; + let size = 0; + while (this.data[this.offset] !== 255) { + const chunkLength = this.head(major); + if (chunkLength === null) badRequest(); + size += chunkLength; + if (size > limit) tooLarge(); + chunks.push(this.take(chunkLength, limit)); + // Bound bookkeeping even for a malicious sequence of empty chunks. + if (chunks.length > 4096) tooLarge(); + } + this.offset++; + return join(chunks, size); + } + take(size: number, limit: number): Uint8Array { + if (size > limit) tooLarge(); + if (size > this.data.length - this.offset) badRequest(); + const value = this.data.subarray(this.offset, this.offset + size); + this.offset += size; + return value; + } + finished(): boolean { + return this.offset === this.data.length; + } + break(): boolean { + if (this.data[this.offset] !== 255) return false; + this.offset++; + return true; + } +} + +export function join(chunks: Uint8Array[], size: number): Uint8Array { + const data = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + data.set(chunk, offset); + offset += chunk.length; + } + return data; +} + +export function decodeEnvelope(data: Uint8Array, store: false, limits: Limits): FetchRequest; +export function decodeEnvelope(data: Uint8Array, store: true, limits: Limits): StoreMetadata; +export function decodeEnvelope( + data: Uint8Array, + store: boolean, + limits: Limits, +): FetchRequest | StoreMetadata { + const decoder = new Decoder(data); + const count = decoder.head(5); + const expected = store ? 3 : 2; + if (count !== null && count !== expected) badRequest(); + const fields = new Map(); + for (let i = 0; i < expected; i++) { + let name: string; + try { + name = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode( + decoder.string(3, 32), + ); + } catch { + badRequest(); + } + const allowed = name === 'key' || name === 'secondary_key' || (store && name === 'value'); + if (!allowed || fields.has(name)) badRequest(); + fields.set(name, decoder.string(2, name === 'value' ? limits.value : limits.key)); + } + if ((count === null && !decoder.break()) || !decoder.finished()) badRequest(); + return { + key: fields.get('key')!, + secondary_key: fields.get('secondary_key')!, + ...(store ? { value: fields.get('value')! } : {}), + }; +} + +function header(major: number, length: number): Uint8Array { + if (length < 24) return Uint8Array.of((major << 5) | length); + if (length <= 255) return Uint8Array.of((major << 5) | 24, length); + if (length <= 65535) return Uint8Array.of((major << 5) | 25, length >> 8, length & 255); + return Uint8Array.of( + (major << 5) | 26, + length >>> 24, + (length >>> 16) & 255, + (length >>> 8) & 255, + length & 255, + ); +} + +export function encodeEnvelope( + fields: Record, +): Uint8Array { + const chunks = [header(5, Object.keys(fields).length)]; + const string = (value: string | Uint8Array) => { + const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value; + chunks.push(header(typeof value === 'string' ? 3 : 2, bytes.length), bytes); + }; + for (const [key, value] of Object.entries(fields)) { + string(key); + if (value === null) chunks.push(Uint8Array.of(246)); + else string(value); + } + return join( + chunks, + chunks.reduce((sum, chunk) => sum + chunk.length, 0), + ); +} + +export function cborResponse(fields: Record): Response { + const body = encodeEnvelope(fields); + return new Response(body, { + headers: { + 'Content-Type': 'application/cbor', + 'Content-Length': String(body.length), + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/database.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/database.ts new file mode 100644 index 0000000000000..a46dc43e54740 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/database.ts @@ -0,0 +1,291 @@ +// @effect-diagnostics cryptoRandomUUID:off globalConsole:off - vendored upstream protocol, not an Effect service. +import { HttpError, unavailable } from './errors.ts'; +import { GRACE_SECONDS, LEASE_SECONDS } from './limits.ts'; +import { measured, type Observations } from './observations.ts'; + +export interface Scope { + scope_id: string; + endpoint: string; + repository_id: string; + repository_owner_id: string; + branch: string; + policy_version: number; + writes_enabled: number; + // 1 when `pull_request` runs whose PR targets `branch` may also publish. + pull_requests: number; +} +export interface Generation { + generation_id: string; + scope_id: string; + value_object: string; + blob_object: string; + blob_id: string | null; + value_size: number; + blob_size: number; +} +export interface Selection extends Generation { + kind: 'exact' | 'fallback'; + key: number[]; +} + +// Preserve empty and non-UTF-8 keys as BLOBs, without text or hash conversions. +export function binary(bytes: Uint8Array): ArrayBuffer { + return bytes.slice().buffer; +} + +export async function getScope(db: D1Database, id: string, stats?: Observations): Promise { + let scope: Scope | null; + try { + const { results } = await measured( + db + .prepare(`SELECT s.*, (s.writes_enabled AND d.writes_enabled) AS writes_enabled + FROM scopes s, deployment d WHERE s.scope_id = ? AND s.enabled = 1 AND d.enabled = 1`) + .bind(id) + .all(), + stats, + ); + scope = results[0] ?? null; + } catch { + unavailable(); + } + if (!scope) throw new HttpError(404, 'unknown_scope'); + return scope; +} + +export async function selectEntry( + db: D1Database, + scope: string, + key: Uint8Array, + secondary: Uint8Array, + stats?: Observations, +): Promise { + // Both branches use indexed identities in one SQLite snapshot. + try { + const { results } = await measured( + db + .prepare(` + SELECT g.*, e.key, 'exact' AS kind, 0 AS priority + FROM entries e JOIN generations g ON g.generation_id = e.generation_id + JOIN scopes s ON s.scope_id = e.scope_id CROSS JOIN deployment d + WHERE e.scope_id = ?1 AND e.key = ?2 AND g.state = 'ready' AND g.expires_at > unixepoch() + AND s.enabled = 1 AND d.enabled = 1 + UNION ALL + SELECT g.*, e.key, 'fallback' AS kind, 1 AS priority + FROM associations a JOIN entries e ON e.scope_id = a.scope_id AND e.key = a.target_key + JOIN generations g ON g.generation_id = e.generation_id + JOIN scopes s ON s.scope_id = e.scope_id CROSS JOIN deployment d + WHERE a.scope_id = ?1 AND a.secondary_key = ?3 AND g.state = 'ready' AND g.expires_at > unixepoch() + AND s.enabled = 1 AND d.enabled = 1 + ORDER BY priority LIMIT 1`) + .bind(scope, binary(key), binary(secondary)) + .all(), + stats, + ); + return results[0] ?? null; + } catch { + unavailable(); + } +} + +export async function selectBlob( + db: D1Database, + scope: string, + blob: string, + stats?: Observations, +): Promise { + try { + const { results } = await measured( + db + .prepare(`SELECT g.* FROM generations g JOIN scopes s ON s.scope_id = g.scope_id CROSS JOIN deployment d + WHERE g.scope_id = ? AND g.blob_id = ? AND s.enabled = 1 AND d.enabled = 1 + AND ((g.state = 'ready' AND g.expires_at > unixepoch()) OR (g.state = 'retired' AND g.gc_after > unixepoch()))`) + .bind(scope, blob) + .all(), + stats, + ); + return results[0] ?? null; + } catch { + unavailable(); + } +} + +export async function reserve( + db: D1Database, + scope: Scope, + tokenExp: number, + bytes: number, + stats?: Observations, +): Promise { + const id = crypto.randomUUID(); + const prefix = `${scope.scope_id}/${id}`; + try { + await measured( + db + .prepare(`INSERT INTO generations + (generation_id, scope_id, state, policy_version, token_exp, lease_until, gc_after, value_object, blob_object, charged_bytes) + VALUES (?, ?, 'uploading', ?, ?, unixepoch() + ?, unixepoch() + ?, ?, ?, ?)`) + .bind( + id, + scope.scope_id, + scope.policy_version, + tokenExp, + LEASE_SECONDS, + LEASE_SECONDS + GRACE_SECONDS, + `${prefix}/value`, + `${prefix}/blob`, + bytes, + ) + .run(), + stats, + ); + } catch { + unavailable(); + } + return { + generation_id: id, + scope_id: scope.scope_id, + value_object: `${prefix}/value`, + blob_object: `${prefix}/blob`, + blob_id: null, + value_size: 0, + blob_size: 0, + }; +} + +export async function recordMultipart( + db: D1Database, + generation: Generation, + uploadId: string, + stats?: Observations, +): Promise { + const result = await measured( + db + .prepare( + `UPDATE generations SET upload_id = ? WHERE generation_id = ? AND state = 'uploading' AND lease_until > unixepoch()`, + ) + .bind(uploadId, generation.generation_id) + .run(), + stats, + ); + if (result.meta.changes !== 1) unavailable(); +} + +export async function publish( + db: D1Database, + generation: Generation, + key: Uint8Array, + secondary: Uint8Array, + stats?: Observations, +): Promise { + const actual = generation.value_size + generation.blob_size; + let results: D1Result[]; + try { + results = await measured( + db.batch([ + db + .prepare(`UPDATE generations SET state = 'ready', key = ?, secondary_key = ?, blob_id = ?, + value_size = ?, blob_size = ?, charged_bytes = ?, + expires_at = unixepoch() + (SELECT retention_seconds FROM scopes WHERE scope_id = generations.scope_id), + gc_after = unixepoch() + (SELECT retention_seconds FROM scopes WHERE scope_id = generations.scope_id) + WHERE generation_id = ? AND state = 'uploading' AND lease_until > unixepoch() AND token_exp > unixepoch() + AND charged_bytes >= ? AND EXISTS ( + SELECT 1 FROM scopes s, deployment d WHERE s.scope_id = generations.scope_id + AND s.policy_version = generations.policy_version AND s.enabled = 1 AND s.writes_enabled = 1 + AND d.enabled = 1 AND d.writes_enabled = 1 AND s.charged_bytes <= s.byte_limit AND d.charged_bytes <= d.byte_limit + ) RETURNING generation_id`) + .bind( + binary(key), + binary(secondary), + generation.blob_id, + generation.value_size, + generation.blob_size, + actual, + generation.generation_id, + actual, + ), + ]), + stats, + ); + } catch { + unavailable(); + } + if (results[0]?.results.length !== 1) throw new HttpError(503, 'publication_guard'); +} + +export async function abandon(db: D1Database, id: string): Promise { + // Keep charges during the grace period for R2 operations that finish late. + await db + .prepare(`UPDATE generations SET lease_until = min(lease_until, unixepoch()), gc_after = min(gc_after, unixepoch() + ?) + WHERE generation_id = ? AND state = 'uploading'`) + .bind(GRACE_SECONDS, id) + .run(); +} + +export async function cleanup(env: Env): Promise { + const limit = Number(env.GC_BATCH_SIZE); + if (!Number.isInteger(limit) || limit < 1 || limit > 256) + throw new Error('Invalid GC batch size'); + const claim = crypto.randomUUID(); + const claimed = + await env.INDEX.prepare(`UPDATE generations SET state = 'deleting', gc_claim = ?, gc_after = unixepoch() + 600 + WHERE generation_id IN (SELECT generation_id FROM generations WHERE gc_after <= unixepoch() ORDER BY gc_after LIMIT ?) + RETURNING generation_id, value_object, blob_object, upload_id`) + .bind(claim, limit) + .all<{ + generation_id: string; + value_object: string; + blob_object: string; + upload_id: string | null; + }>(); + let deleted = 0; + for (const generation of claimed.results) { + try { + if (generation.upload_id) + await env.ARTIFACTS.resumeMultipartUpload( + generation.blob_object, + generation.upload_id, + ).abort(); + await env.ARTIFACTS.delete([generation.value_object, generation.blob_object]); + // FK deletion only removes entries still selecting this generation. + await env.INDEX.prepare( + `DELETE FROM generations WHERE generation_id = ? AND state = 'deleting' AND gc_claim = ?`, + ) + .bind(generation.generation_id, claim) + .run(); + deleted++; + } catch { + console.warn( + JSON.stringify({ + operation: 'cleanup', + error: 'deletion_failed', + generation: generation.generation_id, + }), + ); + } + } + await cleanAssociations(env.INDEX, limit); + console.log(JSON.stringify({ operation: 'cleanup', claimed: claimed.results.length, deleted })); +} + +async function cleanAssociations(db: D1Database, limit: number): Promise { + // Cursor bounds scanning as well as deletion, even when all associations are live. + const batch = await db + .prepare(`SELECT scope_id, secondary_key FROM associations + WHERE (scope_id, secondary_key) > (SELECT scope_id, secondary_key FROM maintenance WHERE id = 1) + ORDER BY scope_id, secondary_key LIMIT ?`) + .bind(limit) + .all<{ scope_id: string; secondary_key: number[] }>(); + const last = batch.results.at(-1); + await db.batch([ + db + .prepare(`DELETE FROM associations WHERE (scope_id, secondary_key) IN ( + SELECT scope_id, secondary_key FROM associations + WHERE (scope_id, secondary_key) > (SELECT scope_id, secondary_key FROM maintenance WHERE id = 1) + ORDER BY scope_id, secondary_key LIMIT ?) + AND NOT EXISTS (SELECT 1 FROM entries e WHERE e.scope_id = associations.scope_id AND e.key = associations.target_key)`) + .bind(limit), + db + .prepare('UPDATE maintenance SET scope_id = ?, secondary_key = ? WHERE id = 1') + .bind(last?.scope_id ?? '', binary(new Uint8Array(last?.secondary_key ?? []))), + ]); +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/errors.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/errors.ts new file mode 100644 index 0000000000000..9b54ffce50c80 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/errors.ts @@ -0,0 +1,42 @@ +export class HttpError extends Error { + constructor( + public status: number, + public code: string, + ) { + super(code); + } +} + +export function badRequest(): never { + throw new HttpError(400, 'invalid_request'); +} + +export function tooLarge(): never { + throw new HttpError(413, 'size_limit'); +} + +export function unavailable(): never { + throw new HttpError(503, 'unavailable'); +} + +export function errorResponse(error: unknown): Response { + const status = error instanceof HttpError ? error.status : 500; + const messages: Record = { + 400: 'Invalid request', + 401: 'Invalid credentials', + 403: 'Write not permitted', + 404: 'Not found', + 413: 'Request too large', + 429: 'Rate limit exceeded', + 500: 'Operation failed', + 503: 'Service unavailable', + }; + return new Response(messages[status] ?? messages[500], { + status, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + ...(status === 429 || status === 503 ? { 'Retry-After': '60' } : {}), + }, + }); +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/fetch.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/fetch.ts new file mode 100644 index 0000000000000..73b8283e227e1 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/fetch.ts @@ -0,0 +1,42 @@ +import { decodeEnvelope } from './cbor.ts'; +import { selectEntry } from './database.ts'; +import { badRequest, HttpError, unavailable } from './errors.ts'; +import type { Limits } from './limits.ts'; +import { parameters } from './multipart.ts'; +import type { Observations } from './observations.ts'; +import { contentLength, Deadline, readBody } from './streams.ts'; + +type FetchResult = + | { kind: 'exact'; value: Uint8Array; blob_id: string | null } + | { kind: 'fallback'; key: Uint8Array }; + +export async function fetchMetadata( + request: Request, + env: Pick, + scope: string, + limits: Limits, + deadline: Deadline, + stats: Observations, +): Promise { + if (parameters(request.headers.get('Content-Type') ?? '').type !== 'application/cbor') + badRequest(); + contentLength(request, limits.fetch); + const body = await readBody(request.body, limits.fetch, deadline); + stats.request_bytes = body.length; + const data = decodeEnvelope(body, false, limits); + const selected = await deadline.run( + selectEntry(env.INDEX, scope, data.key, data.secondary_key, stats), + ); + if (!selected) throw new HttpError(404, 'miss'); + // Fallbacks only identify the associated key. They must not depend on R2 availability. + if (selected.kind === 'fallback') return { kind: 'fallback', key: new Uint8Array(selected.key) }; + + stats.r2_operations++; + const object = await deadline + .run(env.ARTIFACTS.get(selected.value_object)) + .catch(() => unavailable()); + if (!object || object.size !== selected.value_size || object.size > limits.value) unavailable(); + const value = await readBody(object.body, limits.value, deadline).catch(() => unavailable()); + if (value.length !== selected.value_size) unavailable(); + return { kind: 'exact', value, blob_id: selected.blob_id }; +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/index.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/index.ts new file mode 100644 index 0000000000000..5b7c0cb9c64b3 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/index.ts @@ -0,0 +1,130 @@ +import { authorize, githubKeys, type WriteIdentity } from './auth.ts'; +import { cborResponse } from './cbor.ts'; +import { cleanup, getScope, selectBlob } from './database.ts'; +import { errorResponse, HttpError, unavailable } from './errors.ts'; +import { fetchMetadata } from './fetch.ts'; +import { limitsFrom } from './limits.ts'; +import { store } from './store.ts'; +import { Deadline } from './streams.ts'; +import { Admission } from './admission.ts'; +import { Observations } from './observations.ts'; + +const keys = githubKeys(); +const admission = new Admission(); + +export default { + async fetch(request, env, ctx) { + const started = Date.now(); + const id = crypto.randomUUID(); + const stats = new Observations(); + let scopeId = 'unknown'; + let operation = 'unknown'; + let outcome = 'error'; + let identity: WriteIdentity | undefined; + let deadline: Deadline | undefined; + let release: (() => void) | undefined; + let response: Response; + try { + const limits = limitsFrom(env.LIMITS); + const url = new URL(request.url); + deadline = new Deadline( + url.pathname.endsWith('/store') ? limits.deadlineMs : Math.min(limits.deadlineMs, 15000), + request.signal, + ); + const route = + /^\/projects\/([a-z0-9][a-z0-9-]{0,62})\/(fetch|store|blob\/([0-9a-f-]{36}))$/.exec( + url.pathname, + ); + const namespaces: unknown = JSON.parse(env.NAMESPACES); + if ( + !Array.isArray(namespaces) || + namespaces.length > 100 || + namespaces.some((value) => typeof value !== 'string') + ) + throw new Error('Invalid namespaces'); + const known = route && namespaces.includes(route[1]); + if (known) { + scopeId = route[1]!; + operation = route[2]!.startsWith('blob/') ? 'blob' : route[2]!; + } + const limiter = + operation === 'store' || url.pathname.endsWith('/store') + ? env.STORE_LIMITER + : env.READ_LIMITER; + const rateLimit = await deadline.run( + limiter.limit({ key: known ? `${scopeId}:${operation}` : 'unknown' }), + ); + if (!rateLimit.success) throw new HttpError(429, 'rate_limit'); + if (!known || url.search || request.method !== (operation === 'blob' ? 'GET' : 'POST')) + throw new HttpError(404, 'unknown_route'); + release = admission.acquire(operation === 'store'); + const scope = await deadline.run(getScope(env.INDEX, scopeId, stats)); + if (operation === 'store') { + identity = await deadline.run(authorize(request, scope, keys)); + response = await store(request, env, ctx, scope, identity, limits, deadline, stats); + outcome = 'stored'; + } else if (operation === 'fetch') { + const result = await fetchMetadata(request, env, scopeId, limits, deadline, stats); + response = cborResponse(result); + outcome = result.kind; + } else { + const selected = await deadline.run(selectBlob(env.INDEX, scopeId, route[3]!, stats)); + if (!selected) throw new HttpError(404, 'blob_missing'); + stats.r2_operations++; + const object = await deadline + .run(env.ARTIFACTS.get(selected.blob_object)) + .catch(() => unavailable()); + if (!object) throw new HttpError(404, 'blob_missing'); + if (object.size !== selected.blob_size) unavailable(); + response = new Response(object.body, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(object.size), + 'Cache-Control': 'no-store', + }, + }); + outcome = 'blob'; + } + } catch (error) { + response = errorResponse(error); + if (error instanceof HttpError) outcome = error.code; + } finally { + deadline?.dispose(); + release?.(); + } + response.headers.set('X-Request-Id', id); + response.headers.set('X-Remote-Cache-Deployment', env.DEPLOYMENT_ID ?? 'local'); + const sample = Math.min(1, Math.max(0, Number(env.LOG_SAMPLE_RATE) || 0)); + // Fixed sampling bounds error volume as well as successful request volume. + if (Math.random() < sample) + console.log( + JSON.stringify({ + request_id: id, + scope: scopeId, + operation, + status: response.status, + outcome, + ...(response.status >= 400 ? { error_class: outcome } : {}), + response_bytes: Number(response.headers.get('Content-Length') ?? 0), + duration_ms: Date.now() - started, + ...stats.toJSON(), + ...(stats.d1_storage_bytes >= 400000000 ? { warning: 'd1_storage_400mb' } : {}), + ...(identity + ? { + repository_id: identity.repository_id, + workflow_ref: identity.workflow_ref, + run_id: identity.run_id, + run_attempt: identity.run_attempt, + sha: identity.sha, + event_name: identity.event_name, + head_ref: identity.head_ref, + } + : {}), + }), + ); + return response; + }, + async scheduled(_event, env) { + await cleanup(env); + }, +} satisfies ExportedHandler; diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/limits.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/limits.ts new file mode 100644 index 0000000000000..b3c1f2650f53f --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/limits.ts @@ -0,0 +1,42 @@ +export const MiB = 1024 * 1024; +export const PART_SIZE = 5 * MiB; +export const LEASE_SECONDS = 15 * 60; +export const GRACE_SECONDS = 10 * 60; + +export const defaults = { + key: 16 * 1024, + value: 4 * MiB, + metadata: 5 * MiB, + fetch: 40 * 1024, + blob: 64 * MiB, + store: 72 * MiB, + headers: 8 * 1024, + deadlineMs: 120_000, +}; +export type Limits = typeof defaults; + +export function limitsFrom(raw: string): Limits { + const input: unknown = JSON.parse(raw); + if (!input || typeof input !== 'object' || Array.isArray(input)) + throw new Error('Invalid limits'); + const result = { ...defaults }; + for (const [name, value] of Object.entries(input)) { + if ( + !(name in defaults) || + !Number.isSafeInteger(value) || + value <= 0 || + value > defaults[name as keyof Limits] + ) { + throw new Error('Limits must be positive integers no larger than the tested defaults'); + } + result[name as keyof Limits] = value; + } + if ( + result.metadata < 2 * result.key + result.value + 128 || + result.fetch < 2 * result.key + 128 || + result.store < result.metadata + result.blob + 1024 + ) { + throw new Error('Envelope limits must accommodate field limits and framing'); + } + return result; +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/multipart.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/multipart.ts new file mode 100644 index 0000000000000..cacbdd6722166 --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/multipart.ts @@ -0,0 +1,120 @@ +import { Buffer } from 'node:buffer'; +import { badRequest, tooLarge } from './errors.ts'; +import { collect, Input } from './streams.ts'; + +export function parameters(raw: string): { type: string; params: Map } { + const separator = raw.indexOf(';'); + const type = (separator === -1 ? raw : raw.slice(0, separator)).trim().toLowerCase(); + let rest = separator === -1 ? '' : raw.slice(separator); + const params = new Map(); + while (rest.length) { + const match = /^;\s*([\w-]+)\s*=\s*(?:"((?:[^"\\\r\n]|\\[^\r\n])*)"|([^\s;]+))\s*/.exec(rest); + if (!match) badRequest(); + const name = match[1]!.toLowerCase(); + if (params.has(name)) badRequest(); + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3]!); + rest = rest.slice(match[0].length); + } + return { type, params }; +} + +export function boundaryFrom(contentType: string | null): string { + const { type, params } = parameters(contentType ?? ''); + const boundary = params.get('boundary'); + if ( + type !== 'multipart/form-data' || + !boundary || + boundary.length > 70 || + !/^[0-9A-Za-z'()+_,./:=? -]+$/.test(boundary) || + boundary.endsWith(' ') + ) + badRequest(); + return boundary; +} + +export async function* multipart( + input: Input, + boundary: string, + headerLimit: number, +): AsyncGenerator<{ + name: 'metadata' | 'blob'; + body: AsyncGenerator; +}> { + let preamble = 0; + while (true) { + const line = await input.until(Buffer.from('\r\n'), headerLimit); + preamble += line.length + 2; + if (preamble > headerLimit) tooLarge(); + if (line.toString('utf8').replace(/[ \t]+$/, '') === `--${boundary}`) break; + } + const delimiter = Buffer.from(`\r\n--${boundary}`); + const seen = new Set(); + let closed = false; + while (!closed) { + if (seen.size >= 2) badRequest(); + const raw = await input.until(Buffer.from('\r\n\r\n'), headerLimit); + const headers = new Map(); + for (const line of raw.toString('utf8').split('\r\n')) { + const match = /^([!#$%&'*+.^_`|~\w-]+):[ \t]*([^\r\n]*)$/.exec(line); + if (!match) badRequest(); + const name = match[1]!.toLowerCase(); + if (headers.has(name)) badRequest(); + headers.set(name, match[2]!.trim()); + } + const disposition = parameters(headers.get('content-disposition') ?? ''); + const name = disposition.params.get('name'); + if ( + disposition.type !== 'form-data' || + (name !== 'metadata' && name !== 'blob') || + seen.has(name) + ) + badRequest(); + if (headers.has('content-transfer-encoding')) badRequest(); + const expected = name === 'metadata' ? 'application/cbor' : 'application/octet-stream'; + if (parameters(headers.get('content-type') ?? '').type !== expected) badRequest(); + seen.add(name); + let consumed = false; + async function* body(): AsyncGenerator { + while (true) { + let index = input.buffer.indexOf(delimiter); + while (index >= 0) { + const match = await delimiterEnd(input, index + delimiter.length, headerLimit); + if (match) { + if (index) yield input.take(index); + input.take(match.end - index); + closed = match.closed; + consumed = true; + return; + } + index = input.buffer.indexOf(delimiter, index + 1); + } + const safe = input.buffer.length - delimiter.length - 2; + if (safe > 0) yield input.take(safe); + if (!(await input.fill())) badRequest(); + } + } + yield { name, body: body() }; + if (!consumed) throw new Error('Multipart consumer must drain each part'); + } + if (!seen.has('metadata')) badRequest(); + await collect(input.rest(), headerLimit); +} + +async function delimiterEnd( + input: Input, + start: number, + limit: number, +): Promise<{ end: number; closed: boolean } | null> { + while (input.buffer.length < start + 2 && (await input.fill())) {} + const closed = input.buffer[start] === 45 && input.buffer[start + 1] === 45; + let end = start + (closed ? 2 : 0); + while (true) { + while (input.buffer.length < end + 2 && (await input.fill())) {} + if (input.buffer[end] !== 32 && input.buffer[end] !== 9) break; + if (++end - start > limit) tooLarge(); + } + if (input.buffer[end] === 13 && input.buffer[end + 1] === 10) return { end: end + 2, closed }; + if (closed && input.ended && end === input.buffer.length) return { end, closed }; + // A boundary prefix followed by arbitrary bytes is still part of the opaque body. + return null; +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/observations.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/observations.ts new file mode 100644 index 0000000000000..5540748cb370d --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/observations.ts @@ -0,0 +1,42 @@ +// @effect-diagnostics globalDate:off - vendored upstream protocol, not an Effect service. +export class Observations { + request_bytes = 0; + d1_rows_read = 0; + d1_rows_written = 0; + d1_ms = 0; + d1_queries = 0; + d1_storage_bytes = 0; + r2_operations = 0; + + toJSON() { + return { + request_bytes: this.request_bytes, + d1_rows_read: this.d1_rows_read, + d1_rows_written: this.d1_rows_written, + d1_ms: this.d1_ms, + d1_queries: this.d1_queries, + d1_storage_bytes: this.d1_storage_bytes, + r2_operations: this.r2_operations, + }; + } +} + +export async function measured( + operation: Promise, + stats?: Observations, +): Promise { + const start = Date.now(); + try { + const result = await operation; + if (stats) + for (const item of Array.isArray(result) ? result : [result]) { + stats.d1_rows_read += item.meta.rows_read; + stats.d1_rows_written += item.meta.rows_written; + stats.d1_storage_bytes = item.meta.size_after; + stats.d1_queries++; + } + return result; + } finally { + if (stats) stats.d1_ms += Date.now() - start; + } +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/store.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/store.ts new file mode 100644 index 0000000000000..0e73ecf8054ed --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/store.ts @@ -0,0 +1,131 @@ +// @effect-diagnostics cryptoRandomUUID:off globalConsole:off - vendored upstream protocol, not an Effect service. +import { cborResponse, decodeEnvelope, type StoreMetadata } from './cbor.ts'; +import { + abandon, + publish, + recordMultipart, + reserve, + type Generation, + type Scope, +} from './database.ts'; +import { badRequest, tooLarge, unavailable } from './errors.ts'; +import { PART_SIZE, type Limits } from './limits.ts'; +import { boundaryFrom, multipart } from './multipart.ts'; +import { collect, contentLength, Deadline, Input } from './streams.ts'; +import type { WriteIdentity } from './auth.ts'; +import type { Observations } from './observations.ts'; + +async function uploadBlob( + env: Env, + generation: Generation, + source: AsyncIterable, + limit: number, + deadline: Deadline, + stats: Observations, +): Promise { + let buffer = new Uint8Array(PART_SIZE); + let used = 0; + let upload: R2MultipartUpload | undefined; + const parts: R2UploadedPart[] = []; + for await (const chunk of source) { + generation.blob_size += chunk.length; + if (generation.blob_size > limit) tooLarge(); + let offset = 0; + while (offset < chunk.length) { + if (used === PART_SIZE) { + if (!upload) { + stats.r2_operations++; + upload = await deadline.run(env.ARTIFACTS.createMultipartUpload(generation.blob_object)); + try { + await deadline.run(recordMultipart(env.INDEX, generation, upload.uploadId, stats)); + } catch (error) { + // Also cover creation succeeding but D1 failing to record its ID. + try { + await deadline.run(upload.abort()); + } catch { + /* Lifecycle is the final backstop. */ + } + throw error; + } + } + stats.r2_operations++; + parts.push(await deadline.run(upload.uploadPart(parts.length + 1, buffer))); + buffer = new Uint8Array(PART_SIZE); + used = 0; + } + const size = Math.min(PART_SIZE - used, chunk.length - offset); + buffer.set(chunk.subarray(offset, offset + size), used); + used += size; + offset += size; + } + } + if (upload) { + stats.r2_operations += Number(used > 0) + 1; + if (used) + parts.push(await deadline.run(upload.uploadPart(parts.length + 1, buffer.subarray(0, used)))); + await deadline.run(upload.complete(parts)); + } else { + stats.r2_operations++; + const result = await deadline.run( + env.ARTIFACTS.put(generation.blob_object, buffer.subarray(0, used)), + ); + if (!result) unavailable(); + } + generation.blob_id = crypto.randomUUID(); +} + +export async function store( + request: Request, + env: Env, + ctx: Pick, + scope: Scope, + identity: WriteIdentity, + limits: Limits, + deadline: Deadline, + stats: Observations, +): Promise { + const boundary = boundaryFrom(request.headers.get('Content-Type')); + const length = contentLength(request, limits.store); + const generation = await deadline.run( + reserve(env.INDEX, scope, identity.exp, length ?? limits.store, stats), + ); + let input: Input | undefined; + try { + input = new Input(request.body, Math.min(length ?? limits.store, limits.store), deadline); + let metadata: StoreMetadata | undefined; + for await (const part of multipart(input, boundary, limits.headers)) { + if (part.name === 'metadata') { + metadata = decodeEnvelope(await collect(part.body, limits.metadata), true, limits); + generation.value_size = metadata.value.length; + stats.r2_operations++; + const result = await deadline.run( + env.ARTIFACTS.put(generation.value_object, metadata.value), + ); + if (!result) unavailable(); + } else { + await uploadBlob(env, generation, part.body, limits.blob, deadline, stats); + } + } + if (!metadata || (length !== null && input.bytes !== length)) badRequest(); + deadline.check(); + await deadline.run(publish(env.INDEX, generation, metadata.key, metadata.secondary_key, stats)); + return cborResponse({ blob_id: generation.blob_id }); + } catch (error) { + // Publication is never deferred. Only cleanup can continue after the response. + ctx.waitUntil( + abandon(env.INDEX, generation.generation_id).catch(() => { + console.warn( + JSON.stringify({ + operation: 'cleanup', + error: 'abandon_failed', + generation: generation.generation_id, + }), + ); + }), + ); + throw error; + } finally { + stats.request_bytes = input?.bytes ?? 0; + input?.close(); + } +} diff --git a/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/streams.ts b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/streams.ts new file mode 100644 index 0000000000000..c67a9288ad6cc --- /dev/null +++ b/infra/task-cache/vendor/vite-plus-remote-cache-cloudflare/src/streams.ts @@ -0,0 +1,136 @@ +// @effect-diagnostics globalTimers:off - vendored upstream protocol, not an Effect service. +import { Buffer } from 'node:buffer'; +import { badRequest, HttpError, tooLarge } from './errors.ts'; + +export class Deadline { + private controller = new AbortController(); + private timer: ReturnType; + private cancelled: Promise; + private onAbort = () => this.controller.abort(); + constructor( + ms: number, + private signal?: AbortSignal, + ) { + this.timer = setTimeout(this.onAbort, ms); + signal?.addEventListener('abort', this.onAbort, { once: true }); + this.cancelled = new Promise((_, reject) => + this.controller.signal.addEventListener( + 'abort', + () => reject(new HttpError(503, 'deadline')), + { once: true }, + ), + ); + // Cancellation can precede the first awaited operation. + void this.cancelled.catch(() => {}); + if (signal?.aborted) this.onAbort(); + } + check(): void { + if (this.controller.signal.aborted) throw new HttpError(503, 'deadline'); + } + async run(operation: Promise): Promise { + this.check(); + return Promise.race([operation, this.cancelled]); + } + dispose(): void { + clearTimeout(this.timer); + this.signal?.removeEventListener('abort', this.onAbort); + } +} + +export async function collect( + source: AsyncIterable, + limit: number, +): Promise> { + let size = 0; + // Fixed-capacity accumulation also bounds bookkeeping for one-byte chunks. + const buffer = new Uint8Array(limit); + for await (const chunk of source) { + if (chunk.length > limit - size) tooLarge(); + buffer.set(chunk, size); + size += chunk.length; + } + return buffer.subarray(0, size); +} + +export class Input { + private reader: ReadableStreamDefaultReader; + private pending: Uint8Array = new Uint8Array(0); + private offset = 0; + bytes = 0; + ended = false; + buffer: Buffer = Buffer.alloc(0); + constructor( + body: ReadableStream | null, + private max: number, + private deadline: Deadline, + ) { + if (!body) badRequest(); + this.reader = body.getReader(); + } + async fill(): Promise { + if (this.ended) return false; + if (this.offset === this.pending.length) { + const result = await this.deadline.run(this.reader.read()); + if (result.done) { + this.ended = true; + return false; + } + this.bytes += result.value.length; + if (this.bytes > this.max) tooLarge(); + this.pending = result.value; + this.offset = 0; + } + const next = this.pending.subarray(this.offset, this.offset + 64 * 1024); + this.offset += next.length; + this.buffer = Buffer.concat([this.buffer, next]); + return true; + } + take(size: number): Buffer { + const result = this.buffer.subarray(0, size); + this.buffer = this.buffer.subarray(size); + return result; + } + async until(delimiter: Buffer, limit: number): Promise { + while (true) { + const index = this.buffer.indexOf(delimiter); + if (index >= 0) { + if (index > limit) tooLarge(); + const value = this.take(index); + this.take(delimiter.length); + return value; + } + if (this.buffer.length > limit + delimiter.length) tooLarge(); + if (!(await this.fill())) badRequest(); + } + } + async *rest(): AsyncGenerator { + do { + if (this.buffer.length) yield this.take(this.buffer.length); + } while (await this.fill()); + } + close(): void { + void this.reader.cancel().catch(() => {}); + } +} + +export async function readBody( + body: ReadableStream | null, + limit: number, + deadline: Deadline, +): Promise> { + const input = new Input(body, limit, deadline); + try { + return await collect(input.rest(), limit); + } finally { + input.close(); + } +} + +export function contentLength(request: Request, maximum: number): number | null { + const raw = request.headers.get('Content-Length'); + if (raw === null) return null; + if (!/^\d+$/.test(raw)) badRequest(); + const length = Number(raw); + if (!Number.isSafeInteger(length) || length > maximum) tooLarge(); + return length; +} diff --git a/knip.jsonc b/knip.jsonc index 30e289a534da5..ed757fe9c20c9 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -115,6 +115,19 @@ // React Native codegen discovers component specifications by filename. "entry": ["src/*NativeComponent.ts!"], }, + "infra/task-cache": { + "ignore": ["vendor/**"], + // The bootstrap and Vite+ hook are loaded by shell/package configuration. + "entry": [ + "alchemy.run.ts!", + "scripts/install.ts", + "scripts/install-ci.ts", + "src/runHook.ts", + "src/flush.ts", + "src/nativeRunHook.js", + "src/preload.mjs", + ], + }, "infra/relay": { "entry": ["alchemy.run.ts!", "src/persistence/schema.ts!"], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d444d4b05e800..2cc59745d095e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1303,6 +1303,37 @@ importers: specifier: 1.3.0 version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + infra/task-cache: + dependencies: + alchemy: + specifier: 2.0.0-beta.80 + version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(c74a1cfb3df8b67fc508b7f3e6f07baa) + effect: + specifier: 4.0.5 + version: 4.0.5 + jose: + specifier: 'catalog:' + version: 6.2.2 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260601.1 + version: 4.20260604.1 + '@effect/platform-node': + specifier: 4.0.5 + version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.5 + version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + typescript: + specifier: 'catalog:' + version: '@tsc-rs/typescript@0.8.0' + vite-plus: + specifier: 1.3.0 + version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + oxlint-plugin-t3code: dependencies: '@effect/platform-node': @@ -17047,6 +17078,64 @@ snapshots: - typescript - utf-8-validate + alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(c74a1cfb3df8b67fc508b7f3e6f07baa): + dependencies: + '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.5) + '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.5) + '@alchemy.run/node-utils': 2.0.0-beta.80 + '@alchemy.run/sigil': 0.1.0-alpha.1(@types/react@19.3.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@distilled.cloud/acme': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/aws': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/axiom': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/cloudflare': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/doppler': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/fly-io': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/gcp': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/hetzner': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/infisical': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/neon': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/planetscale': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/prisma': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/railway': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/stripe': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/zerossl': 1.0.0-rc.13(effect@4.0.5) + '@effect/sql-d1': 4.0.0(effect@4.0.5) + '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) + '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@neon/functions': 0.11.0(hono@4.13.7) + '@octokit/rest': 22.0.1 + '@octokit/webhooks': 14.2.0 + '@types/aws-lambda': 8.10.161 + capnweb: 0.12.0 + effect: 4.0.5 + fflate: 0.8.3 + libsodium-wrappers: 0.8.4 + pathe: 2.0.3 + picomatch: 4.0.7 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + yaml: 2.9.0 + optionalDependencies: + '@effect/platform-node': 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/sql-pg': 4.0.5(effect@4.0.5) + '@effect/vitest': 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + drizzle-kit: 1.0.0-rc.5-ab785fc + drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.6.5) + mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9) + pg: 8.23.0 + vite: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + ws: 8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - '@types/node' + - '@types/react' + - bufferutil + - hono + - proxy-agent + - react-devtools-core + - typescript + - utf-8-validate + alien-signals@2.0.6: {} am-i-vibing@0.4.0: @@ -18013,6 +18102,22 @@ snapshots: pg: 8.23.0 zod: 4.4.3 + drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.6.5): + optionalDependencies: + '@cloudflare/workers-types': 4.20260604.1 + '@effect/sql-d1': 4.0.0(effect@4.0.5) + '@effect/sql-pg': 4.0.5(effect@4.0.5) + '@effect/sql-sqlite-bun': 4.0.0-rc.112(effect@4.0.5) + '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) + '@electric-sql/pglite': 0.3.15 + '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + bun-types: 1.3.14 + effect: 4.0.5 + expo-sqlite: 58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + pg: 8.23.0 + zod: 4.6.5 + optional: true + dset@3.1.4: {} dunder-proto@1.0.1: diff --git a/vite.config.ts b/vite.config.ts index 8495e43d6dce2..164885182edea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; import * as NodeURL from "node:url"; +import { remoteCacheUrl } from "./infra/task-cache/src/remoteCache.ts"; const DOM_TEST_ENVIRONMENT_ALLOWLIST: Array = []; @@ -78,7 +79,17 @@ const RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS = { "Pick a glyph by meaning from PullRequestGlyph in apps/web/src/components/pullRequest/pullRequestIcons.tsx so every surface draws the same pull request the same way.", }; +// Vite+ supports remote configuration before its published types include it. +const cacheUrl = remoteCacheUrl(import.meta.dirname); +const taskCacheConfig = { + scripts: false, + ...(cacheUrl ? { remote: { url: cacheUrl } } : {}), +}; + export default defineConfig({ + run: { + cache: taskCacheConfig, + }, resolve: { alias: { "~": NodeURL.fileURLToPath(new URL("./apps/web/src", import.meta.url)), @@ -86,6 +97,8 @@ export default defineConfig({ }, test: { environment: "node", + // Fixture subprocesses must not inherit the outer task runner’s cache policy. + env: { VP_REMOTE_CACHE: "off", VP_REMOTE_CACHE_URL: "" }, exclude: TEST_EXCLUDE, hookTimeout: 60_000, testTimeout: 60_000, @@ -141,6 +154,7 @@ export default defineConfig({ fmt: { ignorePatterns: [ ".repos/**", + "infra/task-cache/vendor/**", // Macroscope requires one glob per line; Markdown formatting corrupts it. ".macroscope/ignore.md", ".alchemy", @@ -175,6 +189,7 @@ export default defineConfig({ ignorePatterns: [ ".repos", ".repos/**", + "infra/task-cache/vendor/**", "dist", "dist-electron", "node_modules", From ebd29ca951e1e9303ef8fd07e72ed52264b61583 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:07:27 -0700 Subject: [PATCH 004/222] fix(mobile): screen lists register with their header, enforced by lint (#24710) Co-authored-by: Claude Opus 5.5 --- .../archive/ArchivedThreadsScreen.tsx | 2 + .../src/features/files/FileTreeBrowser.tsx | 2 + .../pull-requests/PullRequestFiles.tsx | 2 + .../SettingsOpenSourceLicensesRouteScreen.tsx | 2 + .../threads/NewTaskContextPickerScreens.tsx | 2 + .../src/features/threads/ThreadFeed.tsx | 1 + .../threads/ThreadNavigationSidebar.tsx | 3 ++ .../threads/ThreadPullRequestsSheet.tsx | 1 + .../features/threads/ThreadSettingsSheet.tsx | 1 + .../src/features/threads/thread-work-log.tsx | 1 + oxlint-plugin-t3code/index.ts | 2 + .../require-registered-list-scroll.test.ts | 32 +++++++++++++++ .../rules/require-registered-list-scroll.ts | 40 +++++++++++++++++++ vite.config.ts | 5 ++- 14 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 oxlint-plugin-t3code/rules/require-registered-list-scroll.test.ts create mode 100644 oxlint-plugin-t3code/rules/require-registered-list-scroll.ts diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 64443efaa8383..90f722e53669f 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -1,3 +1,4 @@ +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { Button } from "../../components/ui/Button"; import type { EnvironmentProject, @@ -428,6 +429,7 @@ export function ArchivedThreadsScreen(props: { item.key} diff --git a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx index eba94cdf3bd67..e9774d37d4d14 100644 --- a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx @@ -1,3 +1,4 @@ +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { SearchField } from "../../components/ui/SearchField"; import { Toolbar } from "../../components/ui/Toolbar"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; @@ -127,6 +128,7 @@ export function SettingsOpenSourceLicensesRouteScreen() { ) : null} ) : ( + {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- ThreadFeedScrollView is passed through the iOS-only spread below; the prop rejects undefined. */} + {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- Only the non-native workspace layout (Android and the fallback) renders this sidebar, which has no native header to register with. */} + {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- Sheet content; sheets sit outside workspace columns and keep their own header handling. */} + {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- Nested in a fixed-height card inside the thread feed, which owns the header's scroll view. */} { + rule.valid( + "allows a list that renders through a registered scroll view", + `const el = ;`, + ); + + rule.valid( + "allows a keyboard-aware list with its scroll view component", + `const el = ;`, + ); + + rule.invalid( + "reports a list given the other list's scroll prop", + `const el = ;`, + ); + + rule.invalid( + "reports a list with the default scroll view", + `const el = ;`, + (output) => { + assert.match(output, /header-registered scroll view/); + }, + ); +}); diff --git a/oxlint-plugin-t3code/rules/require-registered-list-scroll.ts b/oxlint-plugin-t3code/rules/require-registered-list-scroll.ts new file mode 100644 index 0000000000000..7645ea77c0556 --- /dev/null +++ b/oxlint-plugin-t3code/rules/require-registered-list-scroll.ts @@ -0,0 +1,40 @@ +import { defineRule } from "@oxlint/plugins"; + +// LegendList takes a render function; KeyboardAwareLegendList takes the component itself. +const SCROLL_COMPONENT_PROP: Readonly> = { + LegendList: "renderScrollComponent", + AnimatedLegendList: "renderScrollComponent", + KeyboardAwareLegendList: "ScrollViewComponent", +}; + +// A screen's list must scroll in a scroll view registered with its header, or the header loses +// its scroll-edge blur (and, on the iPhone Duo, the blur's reach under the vertical bar). +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require mobile lists to render through a header-registered scroll view (renderScrollComponent).", + }, + }, + create(context) { + return { + JSXOpeningElement(node) { + if (node.name.type !== "JSXIdentifier") return; + const prop = SCROLL_COMPONENT_PROP[node.name.name]; + if (!prop) return; + const registered = node.attributes.some( + (attribute) => + attribute.type === "JSXAttribute" && + attribute.name.type === "JSXIdentifier" && + attribute.name.name === prop, + ); + if (registered) return; + context.report({ + node, + message: `Pass ${prop} so the list scrolls in a header-registered scroll view: NativeScrollView on iOS, or a component wrapping NativeScrollViewMarker. A list nested in another scroll surface or sheet can suppress this with a reason.`, + }); + }, + }; + }, +}); diff --git a/vite.config.ts b/vite.config.ts index 164885182edea..63dd5266bf861 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -439,7 +439,10 @@ export default defineConfig({ }, { files: ["apps/mobile/src/**"], - rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, + rules: { + "t3code/no-mobile-uniwind-theme-escape-hatches": "error", + "t3code/require-registered-list-scroll": "error", + }, }, { files: ["apps/mobile/src/**"], From b3d382e1009d5430de8c528aef99a39854a7cbc4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:07:32 -0700 Subject: [PATCH 005/222] feat(mobile): search Settings, and keep its sidebar header clean (#24766) Co-authored-by: Claude Opus 5.5 --- .../createWorkspaceStackNavigator.ios.tsx | 15 +- .../features/settings/SettingsRouteScreen.tsx | 294 +++++++++--------- .../settings/components/SettingsScreen.tsx | 3 +- .../features/settings/settings-search.test.ts | 68 ++++ .../src/features/settings/settings-search.ts | 120 +++++++ .../workspace/WorkspaceRootScreen.tsx | 39 +-- .../workspace/WorkspaceTabsContext.tsx | 11 +- .../workspace/WorkspaceToolbar.ios.tsx | 95 +++--- .../workspace/workspace-tabs-state.test.ts | 14 + .../workspace/workspace-tabs-state.ts | 17 +- 10 files changed, 449 insertions(+), 227 deletions(-) create mode 100644 apps/mobile/src/features/settings/settings-search.test.ts create mode 100644 apps/mobile/src/features/settings/settings-search.ts diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index d9c233a90f351..5a42b61d2594c 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -39,7 +39,6 @@ import { workspaceUsesColumns } from "../workspace/workspace-tabs-state"; import { isSettingsRoute, settingsOpenIn } from "../settings/settings-navigation"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { setNativelyPoppingWorkspaceRoute } from "../workspace/workspace-detail-selection"; -import { useWorkspaceSidebarHeaderItem } from "./useWorkspaceSidebarHeaderItem"; import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; @@ -94,26 +93,15 @@ function ColumnScreen(props: { readonly onDismiss: (routeKey: string, native: boolean) => void; readonly onAppearance: (type: "willAppear" | "didAppear" | "willDisappear", key: string) => void; readonly onCompose?: () => void; - /** Settings pages don't draw their own sidebar toggle, unlike threads and pull requests. */ - readonly sidebarToggle?: boolean; readonly navigation: ViewProps["navigation"]; }) { const { descriptor } = props; // Options a screen set inside this workspace tab slot, over its route's. - const slotOptions = slotScreenOptions( + const options = slotScreenOptions( use(WorkspaceSlotOptionsContext), descriptor.route.key, descriptor.options, ); - const sidebarItem = useWorkspaceSidebarHeaderItem(); - // Swapping header components would unmount the native header config, which hides the bar. - const options = - props.sidebarToggle && !slotOptions.unstable_headerLeftItems - ? ({ - ...slotOptions, - unstable_headerLeftItems: () => [sidebarItem], - } as AppNativeStackNavigationOptions as typeof slotOptions) - : slotOptions; const [presented, setPresented] = useState(false); const primaryColumn = useMemo( () => @@ -420,7 +408,6 @@ function WorkspaceColumns( key={screenKey} screenKey={screenKey} descriptor={descriptor} - sidebarToggle={inSettingsSlot} attached={props.detail.some((current) => current.key === route.key)} onDismiss={handleNativeDismiss} onAppearance={followCollapsedSplit} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 826d352fb4a65..2f7406d5b7820 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -11,13 +11,15 @@ import { import { currentWorkspaceSelections } from "../workspace/workspace-selection-store"; import { rememberedSettingsPage } from "../workspace/workspace-tab-navigation"; import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; +import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { ScreenScrollView as ScrollView, ScreenScrollViewBottomInset, } from "../../components/ui/ScreenScrollView"; +import { EmptyState } from "../../components/ui/EmptyState"; import { useAuth, useUser } from "@clerk/expo"; import { useNavigation, useNavigationState } from "@react-navigation/native"; -import { use, useMemo } from "react"; +import { use, useMemo, type ComponentProps } from "react"; import { Platform, View } from "react-native"; import { useWorkspaceSurface } from "../workspace/useWorkspaceSurface"; import { useDeviceType } from "../../native/native-layout-metrics"; @@ -31,17 +33,20 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection, SettingsSidebarContext } from "./components/SettingsSection"; import { SettingsScreen } from "./components/SettingsScreen"; +import type { SettingsPageTarget } from "./components/settings-page-targets"; import { AndroidSettingsEnvironmentFilter, SettingsEnvironmentFilterHeader, } from "./components/SettingsEnvironmentFilterHeader"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; +import { filterSettingsSections } from "./settings-search"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; export function SettingsRouteScreen() { const surface = useWorkspaceSurface(); const deviceType = useDeviceType(); const compactTabs = useCompactWorkspaceTabs(); + const workspace = useWorkspaceTabs(); const { layout } = useAdaptiveWorkspaceLayout(); const inSidebar = use(NativePrimaryColumnContext) !== null && layout.usesSplitView; // The page open beside the list: the route right above the Settings tab's route. While @@ -66,7 +71,20 @@ export function SettingsRouteScreen() { {Platform.OS === "android" ? ( - }> + } + search={ + workspace + ? { + value: workspace.state.tabs.settings.query, + onChangeText: (query) => + workspace.dispatch({ type: "query", tab: "settings", query }), + placeholder: "Search settings", + } + : undefined + } + > {content} ) : ( @@ -78,12 +96,14 @@ export function SettingsRouteScreen() { ); } +type SettingsListRow = ComponentProps & { readonly id: string }; + +interface SettingsListSection { + readonly title?: string; + readonly rows: readonly SettingsListRow[]; +} + function ConfiguredSettingsRouteScreen() { - // UIKit's scroll-edge material takes the scroll view's own color; like the chat list, match - // the sidebar's surface so the bar doesn't darken over content at rest. - // Grouped cards only lift off the screen surface; the drawer is as dark as they are. - const listSurface = use(SettingsSidebarContext) ? "bg-drawer" : "bg-screen"; - const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); @@ -95,77 +115,96 @@ function ConfiguredSettingsRouteScreen() { : (user?.primaryEmailAddress?.emailAddress ?? "Signed in"); return ( - - - - - navigation.navigate("SettingsAuth")} - /> - - - - - - - + navigation.navigate("SettingsAuth"), + }, + environmentsRow(Object.keys(savedConnectionsById).length), + { + id: "SettingsNotifications", + icon: "bell.badge", + label: "Notifications", + target: "SettingsNotifications", + }, + ]} + /> ); } function LocalSettingsRouteScreen() { + const { savedConnectionsById } = useSavedRemoteConnections(); + return ; +} + +function environmentsRow(count: number): SettingsListRow { + return { + id: "SettingsEnvironments", + icon: "desktopcomputer", + label: "Environments", + value: `${count}`, + valuePosition: "trailing", + target: "SettingsEnvironments", + }; +} + +function SettingsList(props: { readonly connections: readonly SettingsListRow[] }) { // UIKit's scroll-edge material takes the scroll view's own color; like the chat list, match // the sidebar's surface so the bar doesn't darken over content at rest. // Grouped cards only lift off the screen surface; the drawer is as dark as they are. const listSurface = use(SettingsSidebarContext) ? "bg-drawer" : "bg-screen"; const insets = useSafeAreaInsets(); - const { savedConnectionsById } = useSavedRemoteConnections(); - const environmentCount = Object.keys(savedConnectionsById).length; + const workspace = useWorkspaceTabs(); + const query = workspace?.state.tabs.settings.query ?? ""; + const overflowRows = useOverflowRows(); + const indexSections = useSettingsIndexSections(); + const sections = filterSettingsSections( + [{ rows: overflowRows }, { title: "Connections", rows: props.connections }, ...indexSections], + query, + ); return ( - - - ( + + {section.rows.map(({ id, ...row }) => ( + + ))} + + )) + ) : ( + - - - + )} ); } -function SettingsIndexSections() { +function useSettingsIndexSections(): SettingsListSection[] { const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); const noServerTargets = selectedTargets.length === 0; const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); @@ -182,101 +221,74 @@ function SettingsIndexSections() { members: scopedProjectMembers, }) : (selectedProject?.label ?? "Unavailable project"); - return ( - <> - - - {navigationCustomizationAvailable() ? ( - - ) : null} - {Platform.OS === "ios" ? ( - <> - - - - ) : null} - - - - - - - - {selectedProjectKey !== null ? ( - - ) : null} - - - - - - - - - - - - - - - - - - - ); + const page = ( + target: SettingsPageTarget, + icon: SettingsListRow["icon"], + label: string, + extra?: Pick, + ): SettingsListRow => ({ id: target, icon, label, target, ...extra }); + const serverPage = (target: SettingsPageTarget, icon: SettingsListRow["icon"], label: string) => + page(target, icon, label, { disabled: noServerTargets }); + return [ + { + title: "Interface", + rows: [ + page("SettingsAppearance", "paintbrush", "Appearance"), + ...(navigationCustomizationAvailable() + ? [page("SettingsNavigation", "line.3.horizontal", "Navigation tabs")] + : []), + ...(Platform.OS === "ios" + ? [ + page("SettingsKeyboard", "keyboard", "Keyboard"), + page("SettingsMicrophone", "mic", "Microphone"), + ] + : []), + ], + }, + { + title: "Automations", + rows: [page("SettingsScheduledTasks", "clock", "Scheduled tasks")], + }, + { + title: "Projects & threads", + rows: [ + ...(selectedProjectKey !== null + ? [page("SettingsProjectOverview", "folder", "Overview", { value: projectLabel })] + : []), + page("SettingsOrganization", "folder", "Organization"), + page("SettingsThreads", "text.bubble", "Thread behavior"), + page("SettingsFollowUp", "arrow.turn.left.up", "Follow-ups"), + page("SettingsArchive", "archivebox", "Archived Threads"), + ], + }, + { + title: "Server settings", + rows: [ + serverPage("SettingsProviderAccounts", "person.crop.circle", "Provider accounts"), + serverPage("SettingsEnvironmentNewThreads", "text.bubble", "New threads"), + serverPage("SettingsEnvironmentSourceControl", "arrow.triangle.branch", "Source control"), + serverPage("SettingsEnvironmentAgentBehavior", "text.alignleft", "Agent behavior"), + serverPage("SettingsEnvironmentMaintenance", "arrow.clockwise", "Maintenance"), + ], + }, + { + title: "App", + rows: [page("SettingsAbout", "info.circle", "About T3 Code")], + }, + ]; } -function OverflowSection() { +function useOverflowRows(): SettingsListRow[] { const order = useNavigationTabOrder(); const hidden = useHiddenNavigationTabs(); const { layout } = useAdaptiveWorkspaceLayout(); const tabs = Platform.OS === "android" && layout.usesSplitView ? [] : overflowNavigationTabs(order, hidden); const selectTab = useSelectWorkspaceTab(); - return tabs.length ? ( - - {tabs.map((tab) => ( - selectTab(tab)} - /> - ))} - - ) : null; + return tabs.map((tab) => ({ + id: tab, + icon: NAVIGATION_TAB_ICONS[tab], + label: WORKSPACE_TAB_LABELS[tab], + onPress: () => selectTab(tab), + })); } diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index 85a8258187a17..74a749ed24d49 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -17,7 +17,7 @@ import { export { SettingsScreenContent }; export function SettingsScreen( - props: Pick & { + props: Pick & { readonly children: ReactNode; readonly formSheet?: boolean; readonly workspaceRoot?: boolean; @@ -44,6 +44,7 @@ export function SettingsScreen( navigation.goBack()} diff --git a/apps/mobile/src/features/settings/settings-search.test.ts b/apps/mobile/src/features/settings/settings-search.test.ts new file mode 100644 index 0000000000000..6c11a3dae7f70 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-search.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { filterSettingsSections } from "./settings-search"; + +const SECTIONS = [ + { rows: [{ id: "usage", label: "Usage" }] }, + { + title: "Connections", + rows: [ + { id: "account", label: "T3 Account" }, + { id: "SettingsEnvironments", label: "Environments" }, + ], + }, + { + title: "Interface", + rows: [ + { id: "SettingsAppearance", label: "Appearance" }, + { id: "SettingsKeyboard", label: "Keyboard" }, + ], + }, + { + title: "Server settings", + rows: [ + { id: "SettingsProviderAccounts", label: "Provider accounts" }, + { id: "SettingsEnvironmentSourceControl", label: "Source control" }, + ], + }, +]; + +function visible(query: string) { + return filterSettingsSections(SECTIONS, query).map((section) => [ + section.title ?? null, + section.rows.map((row) => row.label), + ]); +} + +describe("filterSettingsSections", () => { + it.each([ + { + query: "", + expected: SECTIONS.map((section) => [ + section.title ?? null, + section.rows.map((row) => row.label), + ]), + }, + { query: " ", expected: visible("") }, + { query: "appear", expected: [["Interface", ["Appearance"]]] }, + { query: "KEYBOARD", expected: [["Interface", ["Keyboard"]]] }, + { query: "dark mode", expected: [["Interface", ["Appearance"]]] }, + { query: "branch naming", expected: [["Server settings", ["Source control"]]] }, + { + query: "server settings", + expected: [["Server settings", ["Provider accounts", "Source control"]]], + }, + { + query: "accounts", + expected: [["Server settings", ["Provider accounts"]]], + }, + { query: "usage", expected: [[null, ["Usage"]]] }, + { query: "zzzz", expected: [] }, + ])("$query", ({ query, expected }) => { + expect(visible(query)).toEqual(expected); + }); + + it("drops sections that start empty", () => { + expect(filterSettingsSections([{ rows: [] }, ...SECTIONS], "").length).toBe(SECTIONS.length); + }); +}); diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts new file mode 100644 index 0000000000000..f82f90dfe8333 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -0,0 +1,120 @@ +import { foldSearchQuery, rankSearchItems, scoreSearchTerms } from "@t3tools/shared/paletteSearch"; + +import type { SettingsPageTarget } from "./components/settings-page-targets"; + +/** What each page holds besides its row's label, so search finds a setting by its own name. */ +const SETTINGS_SEARCH_TERMS = { + account: ["sign in", "sign out", "login", "clerk", "connect"], + SettingsEnvironments: [ + "servers", + "connections", + "pair", + "add environment", + "remote", + "tailscale", + ], + SettingsNotifications: ["push", "alerts", "live activity", "agent activity"], + SettingsAppearance: [ + "theme", + "dark mode", + "light mode", + "colors", + "font size", + "text size", + "code", + "diffs", + "terminal", + "word break", + "math", + ], + SettingsNavigation: ["tabs", "tab order", "reorder", "hide tabs"], + SettingsKeyboard: ["return key", "send message", "new line", "hardware keyboard"], + SettingsMicrophone: ["voice", "dictation", "audio input", "bluetooth"], + SettingsScheduledTasks: ["automations", "schedule", "cron", "recurring", "webhook"], + SettingsProjectOverview: ["project", "checkouts", "worktrees", "delete project"], + SettingsOrganization: ["project grouping", "group projects", "sidebar"], + SettingsThreads: [ + "auto-settle", + "settle", + "snooze", + "usage limits", + "auto-resume", + "plan mode", + "working section", + ], + SettingsFollowUp: ["queue", "steer", "while the agent is running"], + SettingsArchive: ["archive", "archived", "restore", "unarchive"], + SettingsProviderAccounts: ["codex", "claude", "cursor", "opencode", "sign in", "login", "auth"], + SettingsEnvironmentNewThreads: [ + "default workspace", + "worktree", + "submodules", + "default permissions", + "access", + ], + SettingsEnvironmentSourceControl: [ + "git", + "branch naming", + "pull requests", + "co-authors", + "agent credits", + "default branch", + "automatically pull", + "worktrees", + "start from remote", + ], + SettingsEnvironmentAgentBehavior: ["response streaming", "preview browser", "browser access"], + SettingsEnvironmentMaintenance: ["updates", "provider updates", "restart", "manage environments"], + SettingsAbout: [ + "version", + "diagnostics", + "client storage", + "cache", + "legal", + "privacy", + "terms", + "open source licenses", + ], +} satisfies Partial>; +const termsById: ReadonlyMap = new Map( + Object.entries(SETTINGS_SEARCH_TERMS), +); + +export interface SettingsSearchRow { + readonly id: string; + readonly label: string; +} + +export interface SettingsSearchSection { + readonly title?: string; + readonly rows: readonly Row[]; +} + +/** + * Keeps the rows whose label, section, or page contents match `query`, in list order, and drops + * sections left empty. Matching is the command palette's, so both searches agree. + */ +export function filterSettingsSections< + Row extends SettingsSearchRow, + Section extends SettingsSearchSection, +>(sections: readonly Section[], query: string): Section[] { + const normalizedQuery = foldSearchQuery(query); + const rows = sections.flatMap((section) => + section.rows.map((row) => ({ row, section: section.title })), + ); + const matched = normalizedQuery + ? new Set( + rankSearchItems(rows, ({ row, section }, options) => + scoreSearchTerms( + normalizedQuery, + [row.label, ...(section ? [section] : []), ...(termsById.get(row.id) ?? [])], + options, + ), + ).map(({ item }) => item.row), + ) + : null; + return sections.flatMap((section) => { + const kept = matched ? section.rows.filter((row) => matched.has(row)) : section.rows; + return kept.length ? [{ ...section, rows: kept }] : []; + }); +} diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx index 69efd96411fac..8778ba2b36446 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx @@ -54,16 +54,17 @@ function WorkspaceRootTitle(props: { readonly maxWidth: number; readonly onOpenEnvironments: () => void; }) { - const { state } = useWorkspaceTabs()!; + const { state, settingsOpen } = useWorkspaceTabs()!; const enabled = useEnabledNavigationTabs(); + const tabTitle = settingsOpen + ? "Settings" + : enabled.length + ? TAB_TITLES[state.selectedTab] + : "Navigation"; return ( - } + brand={} maxWidth={props.maxWidth} onPress={props.onOpenEnvironments} /> @@ -106,9 +107,9 @@ function WorkspaceRootContent() { ), [width, navigation], ); - const tab = state.tabs[state.selectedTab]; // Settings is a workspace tab too; its list takes the place of the selected tab's. const settingsOpen = workspace.settingsOpen; + const tab = state.tabs[settingsOpen ? "settings" : state.selectedTab]; const bottomClearance = layout.primarySidebarOverlaysContent && state.selectedTab === "usage" ? workspaceDockBottomClearance({ bottomInset: insets.bottom, keyboardHeight }) @@ -164,8 +165,12 @@ function WorkspaceRootContent() { ? { headerSearchBarOptions: undefined, unstable_headerRightItems: () => [] } : {}), unstable_headerLeftItems: () => [], + // Beside a detail column the tab bar names the tab and the detail header the page, + // so the sidebar keeps the brand. headerTitle: - settingsOpen || state.selectedTab === "usage" ? undefined : renderHeaderTitle, + sidebarNavigation || (!settingsOpen && state.selectedTab !== "usage") + ? renderHeaderTitle + : undefined, }} /> ) : ( @@ -199,10 +204,7 @@ function WorkspaceRootContent() { } > {settingsOpen ? ( - <> - - - + ) : enabled.length === 0 ? ( ) : state.selectedTab === "threads" ? ( @@ -216,14 +218,15 @@ function WorkspaceRootContent() { {deviceType !== "pad" && deviceType !== "phone" && sidebarPicker ? ( ) : null} - {enabled.length === 0 && !settingsOpen ? null : !settingsOpen && - (state.selectedTab === "threads" || state.selectedTab === "pullRequests") ? ( + {/* Android's Settings list carries its own search in its app bar. */} + {enabled.length === 0 && !settingsOpen ? null : ( + settingsOpen + ? Platform.OS === "ios" + : state.selectedTab === "threads" || state.selectedTab === "pullRequests" + ) ? ( ) : ( - + )} diff --git a/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx b/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx index f8e23235d3d80..2c3a10e4b0c43 100644 --- a/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx @@ -14,6 +14,7 @@ import { import { initialWorkspaceTabsState, workspaceTabsReducer, + type WorkspaceSearchTab, type WorkspaceTab, type WorkspaceTabsAction, type WorkspaceTabsState, @@ -25,8 +26,8 @@ export const WorkspaceTabsContext = createContext<{ readonly isTabActive: (tab: WorkspaceTab) => boolean; readonly bottomClearance: number; readonly settingsOpen: boolean; - readonly getScrollOffset: (tab: WorkspaceTab) => number; - readonly recordScrollOffset: (tab: WorkspaceTab, offset: number) => void; + readonly getScrollOffset: (tab: WorkspaceSearchTab) => number; + readonly recordScrollOffset: (tab: WorkspaceSearchTab, offset: number) => void; } | null>(null); export function useWorkspaceTabs() { @@ -47,9 +48,9 @@ export function WorkspaceTabsProvider(props: { activeTab.current = state.selectedTab; }, [state.selectedTab]); const isTabActive = useCallback((tab: WorkspaceTab) => activeTab.current === tab, []); - const scrollOffsets = useRef({ threads: 0, pullRequests: 0, usage: 0 }); - const getScrollOffset = useCallback((tab: WorkspaceTab) => scrollOffsets.current[tab], []); - const recordScrollOffset = useCallback((tab: WorkspaceTab, offset: number) => { + const scrollOffsets = useRef({ threads: 0, pullRequests: 0, usage: 0, settings: 0 }); + const getScrollOffset = useCallback((tab: WorkspaceSearchTab) => scrollOffsets.current[tab], []); + const recordScrollOffset = useCallback((tab: WorkspaceSearchTab, offset: number) => { scrollOffsets.current[tab] = offset; }, []); const context = useMemo( diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx index 17fff053258e7..821c4c70c34b5 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx @@ -19,6 +19,12 @@ import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { useCompactWorkspaceTabs, useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar"; import { WorkspaceTabToolbarButtons } from "./WorkspaceSectionToolbar"; +const SEARCH_LABELS = { + threads: "Search threads", + pullRequests: "Search pull requests", + settings: "Search settings", +} as const; + export function WorkspaceToolbar(props: { readonly onNewTask: () => void; readonly sidebar?: boolean; @@ -34,15 +40,23 @@ export function WorkspaceToolbar(props: { const nativeToolbarCompose = Platform.OS === "ios" && deviceType === "duo-folded" && + !workspace.settingsOpen && workspace.state.selectedTab === "threads" && activeNewTask === null; - const { isTabActive } = workspace; const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true; const focused = useIsFocused(); const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const searchRef = useRef(null); - const tab = workspace.state.tabs[workspace.state.selectedTab]; - const selectedTab = workspace.state.selectedTab; + // The Settings list takes the selected tab's place, so search follows it. + const selectedTab = workspace.settingsOpen ? "settings" : workspace.state.selectedTab; + const tab = workspace.state.tabs[selectedTab]; + const searchLabel = SEARCH_LABELS[selectedTab === "usage" ? "threads" : selectedTab]; + const { isTabActive: isListTabActive } = workspace; + const isTabActive = useCallback( + // A retained tab slot stays mounted while another is shown. + (tab: typeof selectedTab) => (tab === "settings" ? tabActive : isListTabActive(tab)), + [tabActive, isListTabActive], + ); const foldedSearch = deviceType === "duo-folded"; const primaryColumn = use(NativePrimaryColumnContext); // UIKit hosts integrated search outside the sidebar's sliding content. @@ -60,11 +74,11 @@ export function WorkspaceToolbar(props: { if (props.sidebar && !panes.primarySidebarVisible) togglePrimarySidebar(); }, [workspace, selectedTab, props.sidebar, panes.primarySidebarVisible, togglePrimarySidebar]); const focusSearch = useCallback(() => { - if (!workspace.isTabActive(selectedTab)) return false; + if (!isTabActive(selectedTab)) return false; if (duoSearch || !NATIVE_LIQUID_GLASS_SUPPORTED) enterSearch(); searchRef.current?.focus(); return true; - }, [workspace, selectedTab, enterSearch, duoSearch]); + }, [isTabActive, selectedTab, enterSearch, duoSearch]); const cancelSearch = useCallback(() => { workspace.recordScrollOffset(selectedTab, tab.searchEntryOffset); workspace.dispatch({ type: "cancel", tab: selectedTab }); @@ -124,13 +138,7 @@ export function WorkspaceToolbar(props: { const searchButton = foldedSearch ? ( searchRef.current?.cancelSearch() : focusSearch} separateBackground @@ -142,8 +150,13 @@ export function WorkspaceToolbar(props: { optionsVersion={[selectedTab, tab.searchMode]} listeners={{ transitionEnd: restoreSearch }} options={{ + // Without Liquid Glass, Settings keeps its search field out instead of a search button, + // which would take the header's trailing items from the scope filter. headerSearchBarOptions: - (NATIVE_LIQUID_GLASS_SUPPORTED || tab.searchMode !== "resting") && searchVisible + (NATIVE_LIQUID_GLASS_SUPPORTED || + tab.searchMode !== "resting" || + workspace.settingsOpen) && + searchVisible ? { ref: searchRef, autoFocus: tab.searchMode === "active", @@ -160,8 +173,7 @@ export function WorkspaceToolbar(props: { NATIVE_LIQUID_GLASS_SUPPORTED && deviceType !== "phone" && sidebarTabs !== "picker", - placeholder: - selectedTab === "pullRequests" ? "Search pull requests" : "Search threads", + placeholder: searchLabel, cancelButtonHidden: foldedSearch, onFocus: searchFocused, onBlur: () => workspace.dispatch({ type: "expand", tab: selectedTab }), @@ -177,32 +189,33 @@ export function WorkspaceToolbar(props: { : undefined, }} /> - {/* UIKit lays trailing items out from the edge inward. */} - - {searchButton} - {nativeToolbarCompose && foldedSearch ? : null} - {composeButton} - {selectedTab !== "threads" || - isDuoDevice(deviceType) || - deviceType === "pad" || - sidebarTabs === "picker" || - compactTabs ? null : ( - - )} - {NATIVE_LIQUID_GLASS_SUPPORTED ? null : ( - - )} - + {/* UIKit lays trailing items out from the edge inward. A trailing toolbar replaces the + header's own items, so an empty one would hide the Settings scope filter. */} + {workspace.settingsOpen && !foldedSearch ? null : ( + + {searchButton} + {nativeToolbarCompose && foldedSearch ? : null} + {composeButton} + {selectedTab !== "threads" || + isDuoDevice(deviceType) || + deviceType === "pad" || + sidebarTabs === "picker" || + compactTabs ? null : ( + + )} + {NATIVE_LIQUID_GLASS_SUPPORTED ? null : ( + + )} + + )} {sidebarTabs === "toolbar" ? : null} {bottomSearch && searchVisible ? : null} diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts index f476d009857b5..d0f72faa07727 100644 --- a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts @@ -110,6 +110,20 @@ describe("workspace root navigation", () => { expect(workspaceTabsReducer(state, { type: "cancel" }).tabs.threads.scrollOffset).toBe(500); }); + it("keeps the Settings search apart from the tab Settings opened over", () => { + let state = workspaceTabsReducer(initialWorkspaceTabsState(), { + type: "query", + query: "diff", + tab: "threads", + }); + state = workspaceTabsReducer(state, { type: "search", tab: "settings" }); + state = workspaceTabsReducer(state, { type: "query", query: "theme", tab: "settings" }); + expect(state.tabs.settings).toMatchObject({ query: "theme", searchMode: "active" }); + state = workspaceTabsReducer(state, { type: "cancel", tab: "settings" }); + expect(state.tabs.settings).toMatchObject({ query: "", searchMode: "resting" }); + expect(state.tabs.threads.query).toBe("diff"); + }); + it("a late native blur cannot reopen a cancelled search", () => { let state = workspaceTabsReducer(initialWorkspaceTabsState(), { type: "search" }); state = workspaceTabsReducer(state, { type: "cancel" }); diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.ts b/apps/mobile/src/features/workspace/workspace-tabs-state.ts index 6355796c84ef1..f216fb9f13ca0 100644 --- a/apps/mobile/src/features/workspace/workspace-tabs-state.ts +++ b/apps/mobile/src/features/workspace/workspace-tabs-state.ts @@ -12,6 +12,8 @@ export function resolveWorkspaceTabOrder(saved: readonly unknown[] | undefined) } export type WorkspaceTab = NavigationTab; +/** Lists with their own search: the searchable tabs and the Settings list. */ +export type WorkspaceSearchTab = WorkspaceTab | "settings"; export type WorkspaceSearchMode = "resting" | "active" | "expanded"; /** Settings, Chats and pull requests pair a list with a detail column; Usage is one page. */ @@ -29,7 +31,7 @@ export interface WorkspaceTabState { export interface WorkspaceTabsState { readonly selectedTab: WorkspaceTab; readonly selectionRevision: number; - readonly tabs: Readonly>; + readonly tabs: Readonly>; } function emptyTab(): WorkspaceTabState { @@ -44,17 +46,18 @@ export function initialWorkspaceTabsState(): WorkspaceTabsState { threads: emptyTab(), pullRequests: emptyTab(), usage: emptyTab(), + settings: emptyTab(), }, }; } export type WorkspaceTabsAction = | { readonly type: "select"; readonly tab: WorkspaceTab } - | { readonly type: "search"; readonly tab?: WorkspaceTab } - | { readonly type: "query"; readonly query: string; readonly tab?: WorkspaceTab } - | { readonly type: "expand"; readonly tab?: WorkspaceTab } - | { readonly type: "cancel"; readonly tab?: WorkspaceTab } - | { readonly type: "scroll"; readonly tab: WorkspaceTab; readonly offset: number }; + | { readonly type: "search"; readonly tab?: WorkspaceSearchTab } + | { readonly type: "query"; readonly query: string; readonly tab?: WorkspaceSearchTab } + | { readonly type: "expand"; readonly tab?: WorkspaceSearchTab } + | { readonly type: "cancel"; readonly tab?: WorkspaceSearchTab } + | { readonly type: "scroll"; readonly tab: WorkspaceSearchTab; readonly offset: number }; export function workspaceTabsReducer( state: WorkspaceTabsState, @@ -69,7 +72,7 @@ export function workspaceTabsReducer( selectionRevision: state.selectionRevision + 1, }; const tab = action.tab ?? state.selectedTab; - if (tab !== "threads" && tab !== "pullRequests") return state; + if (tab !== "threads" && tab !== "pullRequests" && tab !== "settings") return state; const current = state.tabs[tab]; let next: WorkspaceTabState; switch (action.type) { From 8d3261ebc222f26a402afa2332b599925ddc43b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:41:07 -0700 Subject: [PATCH 006/222] fix(mobile): threads open with less waiting, and the Threads tab remembers drafts (#24750) Co-authored-by: Claude Opus 5.5 --- .../features/home/thread-swipe-actions.tsx | 117 ++++++++++++------ .../layout/AdaptiveWorkspaceLayout.tsx | 2 + .../features/threads/ThreadDetailScreen.tsx | 23 +++- .../workspace/useSelectWorkspaceDetail.ts | 21 +++- .../workspace/workspace-selection-store.ts | 2 +- .../workspace-tab-navigation.test.ts | 24 ++++ .../workspace/workspace-tab-navigation.ts | 22 +++- .../src/persistence/mobile-preferences.ts | 2 + apps/mobile/src/state/threads.ts | 12 ++ 9 files changed, 179 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 493a5848fe596..e75b29a8805ac 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -121,6 +121,9 @@ function resolveSecondaryAction(input: { /** Keep the scroll gate in context so scroll start cannot re-render every row and its subscriptions. */ const SwipeableScrollGateContext = createContext(true); +// Swipeable rebuilds its native pan gesture whenever its config changes identity, so the props +// that feed it stay stable across row re-renders such as selection. +const FAIL_OFFSET_Y: [number, number] = [-10, 10]; export function SwipeableScrollGateProvider(props: { readonly enabled: boolean; @@ -402,11 +405,77 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { }, [content], ); - const handleWillOpen = () => { - if (!props.dormant && !dismissalRef.current && swipeableRef.current) { - props.onSwipeableWillOpen?.(swipeableRef.current); + const { dormant, onSwipeableWillOpen, backgroundColor, containerStyle } = props; + const handleWillOpen = useCallback(() => { + if (!dormant && !dismissalRef.current && swipeableRef.current) { + onSwipeableWillOpen?.(swipeableRef.current); } + }, [dormant, onSwipeableWillOpen]); + const handleSwipeableClose = useCallback(() => { + if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); + }, [onSwipeableClose]); + const compactActions = props.compactActions === true; + const { secondaryAction, threadTitle } = props; + // Swipeable calls this on every render; returning the previous element while nothing it shows + // changed keeps a selection change from re-rendering the hidden actions. + const rightActions = useRef<{ deps: readonly unknown[]; element: ReactNode } | null>(null); + const renderRightActions = ( + _progress: SharedValue, + translation: SharedValue, + methods: SwipeableMethods, + ) => { + const deps = [ + dormant, + translation, + methods, + actionStyle, + backgroundColor, + compactActions, + fullSwipeAction, + fullSwipeThreshold, + handleFullSwipeArmedChange, + primaryAction, + commitPrimaryAction, + onDelete, + secondaryAction, + threadTitle, + ]; + const cached = rightActions.current; + if (cached && deps.every((dep, index) => Object.is(dep, cached.deps[index]))) { + return cached.element; + } + const element = dormant ? null : ( + { + activeTranslationRef.current = translation; + }} + style={actionStyle} + > + methods.close(), + onDelete, + secondaryAction, + threadTitle, + })} + translation={translation} + /> + + ); + rightActions.current = { deps, element }; + return element; }; + const childrenContainerStyle = useMemo(() => ({ backgroundColor }), [backgroundColor]); + const swipeableContainerStyle = useMemo( + () => [{ backgroundColor }, containerStyle], + [backgroundColor, containerStyle], + ); return ( @@ -419,53 +488,21 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { { - if (swipeableRef.current) { - props.onSwipeableClose?.(swipeableRef.current); - } - }} + onSwipeableClose={handleSwipeableClose} onSwipeableRelease={handleRelease} onSwipeableOpenStartDrag={handleWillOpen} onSwipeableWillOpen={handleWillOpen} overshootFriction={1} overshootRight - renderRightActions={(_progress, translation, methods) => - props.dormant ? null : ( - { - activeTranslationRef.current = translation; - }} - style={actionStyle} - > - methods.close(), - onDelete: props.onDelete, - secondaryAction: props.secondaryAction, - threadTitle: props.threadTitle, - })} - translation={translation} - /> - - ) - } + renderRightActions={renderRightActions} rightThreshold={actionsWidth * 0.42} simultaneousWith={props.simultaneousWith} > diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 8de05e3118dae..872f1f521f79a 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -66,6 +66,7 @@ import { type WorkspaceDetail, type WorkspaceDetailSheet, } from "../workspace/workspace-detail-selection"; +import { startOpeningDetail } from "../workspace/useSelectWorkspaceDetail"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { newTaskDestination } from "../threads/new-task-project-selection"; @@ -498,6 +499,7 @@ function AdaptiveWorkspaceLayoutContent( type: "select", tab: detail.name === "Thread" ? "threads" : "pullRequests", }); + startOpeningDetail(detail); navigation.dispatch(selectWorkspaceDetail(detail, sheet)); }); } diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 6b70637f7bd45..40cdfc1e3f83d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -499,6 +499,9 @@ function ThreadVisitWatermark(props: { return null; } +// Past the composer's 180 ms height animation. +const COMPOSER_HEIGHT_SETTLE_MS = 220; + export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetailScreenProps) { const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const deviceType = useDeviceType(); @@ -601,6 +604,24 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const selectedThreadKeyRef = useRef(selectedThreadKey); const lastScrolledSubmittedMessageIdRef = useRef(null); const [composerHeight, setComposerHeight] = useState(null); + // The composer animates its height and reports a layout every frame. The feed follows those + // frames on the UI thread through onComposerLayout; this is only an estimate, so committing + // each frame would re-render the whole screen for the length of the animation. + const composerHeightSettle = useRef | null>(null); + const handleComposerHeightChange = useCallback((height: number) => { + setComposerHeight((current) => current ?? height); + if (composerHeightSettle.current !== null) clearTimeout(composerHeightSettle.current); + composerHeightSettle.current = setTimeout(() => { + composerHeightSettle.current = null; + setComposerHeight(height); + }, COMPOSER_HEIGHT_SETTLE_MS); + }, []); + useEffect( + () => () => { + if (composerHeightSettle.current !== null) clearTimeout(composerHeightSettle.current); + }, + [], + ); const [anchorMessageId, setAnchorMessageId] = useState(null); const [submittedMessageId, setSubmittedMessageId] = useState(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); @@ -1804,7 +1825,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai onUpdateModelSelection={props.onUpdateThreadModelSelection} onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} - onHeightChange={setComposerHeight} + onHeightChange={handleComposerHeightChange} onEditorFocusChange={handleOwnedInputFocusChange} /> diff --git a/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts b/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts index a149212ba4386..59d1ac1fd8dcd 100644 --- a/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts +++ b/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts @@ -1,11 +1,26 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; +import { startOpeningEnvironmentThread } from "../../state/threads"; import { selectWorkspaceDetail, type WorkspaceDetail, type WorkspaceDetailSheet, } from "./workspace-detail-selection"; +const first = (value: string | readonly string[] | undefined) => + typeof value === "string" ? value : value?.[0]; + +/** Starts loading a selected thread before navigation commits; see `startOpeningEnvironmentThread`. */ +export function startOpeningDetail(detail: WorkspaceDetail) { + if (detail.name !== "Thread") return; + const environmentId = first(detail.params?.environmentId); + const threadId = first(detail.params?.threadId); + if (environmentId && threadId) { + startOpeningEnvironmentThread(EnvironmentId.make(environmentId), ThreadId.make(threadId)); + } +} + /** * Shows a page in the detail column the way selecting its list row does. Inside the workspace * layout this also settles its panes; outside it, the root stack's router still does the rest. @@ -15,7 +30,9 @@ export function useSelectWorkspaceDetail() { const { selectDetail } = useAdaptiveWorkspaceLayout(); return ( selectDetail ?? - ((detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => - navigation.dispatch(selectWorkspaceDetail(detail, sheet))) + ((detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => { + startOpeningDetail(detail); + navigation.dispatch(selectWorkspaceDetail(detail, sheet)); + }) ); } diff --git a/apps/mobile/src/features/workspace/workspace-selection-store.ts b/apps/mobile/src/features/workspace/workspace-selection-store.ts index aaabc0f1715e2..e30533e478134 100644 --- a/apps/mobile/src/features/workspace/workspace-selection-store.ts +++ b/apps/mobile/src/features/workspace/workspace-selection-store.ts @@ -51,7 +51,7 @@ function save() { function withoutRouteKeys(saved: WorkspaceSelections | undefined): WorkspaceSelections { const omitKey = ({ key: _, ...rest }: Selection) => rest; - const { threads, pullRequests, settingsKey: _, ...rest } = saved ?? {}; + const { threads, pullRequests, settingsKey: _, threadsDraft: _draft, ...rest } = saved ?? {}; return { ...rest, ...(threads ? { threads: omitKey(threads) } : {}), diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts index a7c84f5f6d5d2..a6f4298d268a3 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts +++ b/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts @@ -96,6 +96,30 @@ describe("opening a workspace tab", () => { expect(back.routes.at(-1)).toMatchObject({ key: "thread", params: thread.params }); }); + it("reopens a draft the tab showed after its last chat, until another chat opens", () => { + const columns = { columns: true, check: available }; + const draft = { key: "draft", name: "Thread", params: { draft: { requestId: "r" } } }; + const withDraft = workspaceSelectionsIn( + [home, draft], + workspaceSelectionsIn([home, thread], {}), + ); + const back = workspaceTabNavigationState( + { routes: [home, settings] }, + "threads", + withDraft, + columns, + ); + expect(back.routes.at(-1)).toEqual(draft); + const afterChat = workspaceSelectionsIn([home, thread], withDraft); + const again = workspaceTabNavigationState( + { routes: [home, settings] }, + "threads", + afterChat, + columns, + ); + expect(again.routes.at(-1)).toMatchObject({ key: "thread", params: thread.params }); + }); + it("falls back to the list for a chat that no longer exists, or an unknown Settings page", () => { const missing = { thread: () => false, pullRequest: () => true }; expect( diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts b/apps/mobile/src/features/workspace/workspace-tab-navigation.ts index 27c11686fe101..1603f7894fe03 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts +++ b/apps/mobile/src/features/workspace/workspace-tab-navigation.ts @@ -91,9 +91,17 @@ export function workspaceSelectionsIn( threadId && (next.threads?.environmentId !== environmentId || next.threads.threadId !== threadId || - next.threads.key !== thread?.key) + next.threads.key !== thread?.key || + next.threadsDraft) ) { - next = { ...next, threads: { environmentId, threadId, key: thread?.key } }; + const { threadsDraft: _, ...rest } = next; + next = { ...rest, threads: { environmentId, threadId, key: thread?.key } }; + } else if ( + thread?.key !== undefined && + !threadId && + (next.threadsDraft?.key !== thread.key || next.threadsDraft.params !== thread.params) + ) { + next = { ...next, threadsDraft: { key: thread.key, params: thread.params } }; } const pullRequestRoute = routes.findLast((route) => route.name === "PullRequest"); const pullRequest = pullRequestSelection(pullRequestRoute?.params); @@ -136,6 +144,16 @@ function detailRoute( check: WorkspaceSelectionCheck, ): RouteLike | undefined { if (tab === "threads") { + const draft = selections.threadsDraft; + if (draft) { + return ( + state.routes.find((route) => route.key === draft.key) ?? { + key: draft.key, + name: "Thread", + params: draft.params as object | undefined, + } + ); + } const thread = selections.threads; if (!thread || !check.thread(thread)) return undefined; return ( diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index c5fd4f487fba6..dda53132870e7 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -71,6 +71,8 @@ const WorkspaceSelections = Schema.Struct({ ), settings: Schema.optional(Schema.String), settingsKey: Schema.optional(Schema.String), + /** A draft the threads tab showed after its last chat; only meaningful in its session. */ + threadsDraft: Schema.optional(Schema.Struct({ key: Schema.String, params: Schema.Unknown })), }); export type WorkspaceSelections = typeof WorkspaceSelections.Type; diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 2b7d4db5b99db..3f8be2e92905d 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -46,6 +46,18 @@ export function useThreadFamily( }, [environmentId, rootThreadId]); } +// Long enough for the opened screen to subscribe; its subscription keeps the thread after this. +const OPENING_THREAD_HOLD_MS = 3_000; + +/** + * Starts loading a thread being opened before its screen renders and subscribes, which + * otherwise waits for navigation to commit. + */ +export function startOpeningEnvironmentThread(environmentId: EnvironmentId, threadId: ThreadId) { + const release = appAtomRegistry.mount(environmentThreads.stateAtom(environmentId, threadId)); + setTimeout(release, OPENING_THREAD_HOLD_MS); +} + const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( Atom.withLabel("mobile-environment-thread:empty"), ); From 15702a9f28620588c0b71811d9d725e98789fdd4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:45:50 -0700 Subject: [PATCH 007/222] fix(mobile): reduce diff entry and source highlighting delays (#24770) Co-authored-by: Claude Opus 5.5 --- .../diffs/nativeReviewDiffSource.test.ts | 29 ++-- .../features/diffs/nativeReviewDiffSource.ts | 5 +- .../pull-requests/PullRequestRouteScreen.tsx | 33 ++++- .../pullRequestDiffHighlightFiles.test.ts | 12 +- .../pullRequestDiffPages.test.ts | 68 ++++++++- .../pull-requests/pullRequestDiffPages.ts | 40 ++++++ .../pull-requests/usePullRequestDiff.test.tsx | 132 +++++++++++++++++- .../pull-requests/usePullRequestDiff.ts | 57 ++++++-- .../pullRequest/PullRequestService.test.ts | 126 +++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 38 ++++- .../src/server/PullRequestProvider.ts | 14 ++ .../server/GitCafePullRequestProvider.test.ts | 118 ++++++++++++++++ .../src/server/GitCafePullRequestProvider.ts | 87 ++++++++---- 13 files changed, 684 insertions(+), 75 deletions(-) diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts index ef2743a34d586..6c2cb8f0d3ad3 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts @@ -58,17 +58,24 @@ const failureKeys = [ ["function", () => () => SOURCE], ] as const; -it.each(failureKeys)("bounds transient retries for a %s key", async (_name, createKey) => { - const key = createKey(); - const fetch = vi.fn(async () => { - throw new Error("Offline"); - }); - const load = createNativeReviewDiffSourceLoader(fetch); - await expect(load(key)).rejects.toThrow("Offline"); - expect(await load(key)).toBeNull(); - expect(await load(key)).toBeNull(); - expect(fetch).toHaveBeenCalledTimes(2); -}); +it.each(failureKeys)( + "recovers after two transient failures on a later load for a %s key", + async (_name, createKey) => { + const key = createKey(); + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error("Offline")) + .mockRejectedValueOnce(new Error("Still offline")) + .mockResolvedValue(SOURCE); + const load = createNativeReviewDiffSourceLoader(fetch); + await expect(load(key)).rejects.toThrow("Offline"); + await expect(load(key)).rejects.toThrow("Still offline"); + expect(fetch).toHaveBeenCalledTimes(2); + expect(await load(key)).toBe(SOURCE); + expect(await load(key)).toBe(SOURCE); + expect(fetch).toHaveBeenCalledTimes(3); + }, +); it.each(failureKeys)( "remembers permanent unavailability for a %s key", diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts index 8dc30c7182533..7f503239c21d2 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts @@ -5,7 +5,6 @@ const MAX_SOURCE_FILES = 4; // Bound retained UTF-16 text across files without discarding an already-fetched // authoritative source just because it is large. Provider response limits still apply. const MAX_CACHED_SOURCE_CHARACTERS = 8_000_000; -const MAX_SOURCE_ATTEMPTS = 2; function isWeakKey(value: unknown): value is object { return (typeof value === "object" && value !== null) || typeof value === "function"; @@ -158,12 +157,12 @@ export function createNativeReviewDiffSourceLoader( permanent, reason: reason ?? "transient", }); - if (permanent || attempt >= MAX_SOURCE_ATTEMPTS) { + if (permanent) { failure.unavailable = true; traceDiffHighlight("source-unavailable", { loader, file, - reason: permanent ? "permanent-error" : "attempts", + reason: "permanent-error", }); return null; } diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index e988da1a0af1b..190d707cc8cf8 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -43,6 +43,7 @@ import { import { usePullRequestDiffHighlightFiles } from "./usePullRequestDiffHighlightFiles"; import { isPullRequestTestFile } from "./pullRequestFileFilters"; import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts"; @@ -88,7 +89,7 @@ import { type NativeReviewDiffViewProps, } from "../diffs/nativeReviewDiffSurface"; import { - buildNativeReviewDiffData, + getCachedNativeReviewDiffData, NATIVE_REVIEW_DIFF_CONTENT_WIDTH, } from "../review/nativeReviewDiffAdapter"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -99,6 +100,7 @@ import { PullRequestActionToolbar, PullRequestCardGroup } from "./PullRequestCon import { pullRequestTarget as requestTarget, usePullRequestDiff, + usePullRequestDiffEntry, type PullRequestDiffParams, type PullRequestParams, } from "./usePullRequestDiff"; @@ -847,16 +849,35 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps) { +>; + +export function PullRequestFileRouteScreen(props: PullRequestFileRouteProps) { + const navigation = + useNavigation>(); + const ready = usePullRequestDiffEntry(navigation); + if (ready) return ; + const title = props.route.params.commit ? "Commit changes" : "Changes"; + return ( + + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + + + ); +} + +function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { const { layout } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); @@ -894,7 +915,7 @@ export function PullRequestFileRouteScreen({ !result.isPending && !result.error; const activity = useEnvironmentQuery(composerPullRequests.activity(requestTarget(route.params))); - const baseData = useMemo(() => buildNativeReviewDiffData(parsed), [parsed]); + const baseData = useMemo(() => getCachedNativeReviewDiffData({ parsedDiff: parsed }), [parsed]); const highlightFiles = usePullRequestDiffHighlightFiles(route.params, parsed, baseData.files); const data = useMemo( () => diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts index 3d419f262da39..b48933573f360 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts @@ -265,8 +265,12 @@ it.each(["permanent", "transient"])( fetch.mockRejectedValueOnce(error); if (failure === "transient") fetch.mockRejectedValueOnce(error); const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache); - if (failure === "transient") await expect(first[1]!.loadContents!()).rejects.toThrow("Offline"); - expect(await first[1]!.loadContents!()).toBeNull(); + if (failure === "transient") { + await expect(first[1]!.loadContents!()).rejects.toThrow("Offline"); + await expect(first[1]!.loadContents!()).rejects.toThrow("Offline"); + } else { + expect(await first[1]!.loadContents!()).toBeNull(); + } const second = createPullRequestDiffHighlightFiles( { target }, { ...parsed, files: [parsed.files[0]!, { ...parsed.files[1]! }] }, @@ -275,8 +279,8 @@ it.each(["permanent", "transient"])( cache, ); expect(await second[1]!.loadContents!()).toBe(contents); - expect(await first[1]!.loadContents!()).toBeNull(); - expect(fetch).toHaveBeenCalledTimes(failure === "permanent" ? 2 : 3); + expect(await first[1]!.loadContents!()).toBe(failure === "permanent" ? null : contents); + expect(fetch).toHaveBeenCalledTimes(failure === "permanent" ? 2 : 4); }, ); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts index a2d06d06ca0c0..ee5f024430747 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { PullRequestDiffResult } from "@t3tools/contracts"; import type { ReviewParsedDiff } from "../review/reviewModel"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { combinePullRequestDiffPages, retainPullRequestDiffPage } from "./pullRequestDiffPages"; +import * as reviewModel from "../review/reviewModel"; +import { + combinePullRequestDiffPages, + getCachedPullRequestDiffPages, + retainPullRequestDiffPage, +} from "./pullRequestDiffPages"; const GITHUB = sourceControlClients.get("github").changeRequest; @@ -24,6 +29,65 @@ function withheldDiff(): PullRequestDiffResult { } describe("paged mobile pull request diffs", () => { + it("isolates scopes, cursors, and provider terminology for the same result", () => { + const result = withheldDiff(); + const pages = [{ cursor: null, result }]; + const first = getCachedPullRequestDiffPages(pages, "first", GITHUB); + const otherScope = getCachedPullRequestDiffPages(pages, "second", GITHUB); + const otherCursor = getCachedPullRequestDiffPages( + [{ cursor: "next", result }], + "first", + GITHUB, + ); + const otherProvider = getCachedPullRequestDiffPages( + pages, + "first", + sourceControlClients.get("gitlab").changeRequest, + ); + expect(otherScope).not.toBe(first); + expect(otherCursor).not.toBe(first); + expect(otherProvider).not.toBe(first); + if (first.kind !== "files" || otherScope.kind !== "files" || otherProvider.kind !== "files") + throw new Error("Expected structured files"); + expect(otherScope.files[0]?.id).not.toBe(first.files[0]?.id); + expect(first.notice).toContain("pull request"); + expect(otherProvider.notice).toContain("merge request"); + expect(getCachedPullRequestDiffPages(pages, "first", GITHUB)).toBe(first); + }); + + it("reuses earlier pages on append and invalidates changed continuation results", () => { + using parse = vi.spyOn(reviewModel, "buildReviewParsedDiff"); + const firstPage = { cursor: null, result: diff("first.ts") }; + const first = getCachedPullRequestDiffPages([firstPage], "append", GITHUB); + const nextPage = { cursor: "next", result: withheldDiff() }; + const combined = getCachedPullRequestDiffPages([firstPage, nextPage], "append", GITHUB); + expect(parse).toHaveBeenCalledTimes(2); + if (first.kind !== "files" || combined.kind !== "files") throw new Error("Expected files"); + expect(combined.files[0]).toBe(first.files[0]); + expect(combined.files.map((file) => file.path)).toEqual(["first.ts", "large.ts"]); + expect(getCachedPullRequestDiffPages([firstPage, nextPage], "append", GITHUB)).toBe(combined); + const refreshed = getCachedPullRequestDiffPages( + [ + firstPage, + { + ...nextPage, + result: { + ...nextPage.result, + omittedFileStats: [{ path: "large.ts", additions: 4000, deletions: 2000 }], + }, + }, + ], + "append", + GITHUB, + ); + expect(parse).toHaveBeenCalledTimes(3); + expect(refreshed).not.toBe(combined); + if (refreshed.kind !== "files") throw new Error("Expected refreshed files"); + expect(refreshed.files[0]).toBe(first.files[0]); + expect(refreshed.files[1]).toMatchObject({ additions: 4000, deletions: 2000 }); + expect(combined.files[1]?.additions).toBe(3000); + }); + it("withheld PR hunks must retain provider line counts", () => { const result = withheldDiff(); const cache = new WeakMap(); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts index c30f3a3198ad5..9abf7cfc0c074 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts @@ -7,6 +7,46 @@ export interface PullRequestDiffPage { readonly result: PullRequestDiffResult; } +interface CachedPullRequestDiffPages { + readonly pages: readonly PullRequestDiffPage[]; + readonly cache: WeakMap; + readonly parsed: ReviewParsedDiff; +} + +// Keep only the latest combination per scoped first page; query results own its lifetime. +const parsedSnapshots = new WeakMap< + PullRequestDiffResult, + Map +>(); + +export function getCachedPullRequestDiffPages( + pages: readonly PullRequestDiffPage[], + scope: string, + terminology: ChangeRequestTerminology, +): ReviewParsedDiff { + const first = pages[0]; + if (!first) return { kind: "empty" }; + const key = JSON.stringify([scope, terminology.singular, first.cursor]); + let snapshots = parsedSnapshots.get(first.result); + if (!snapshots) { + snapshots = new Map(); + parsedSnapshots.set(first.result, snapshots); + } + const previous = snapshots.get(key); + if ( + previous?.pages.length === pages.length && + pages.every((page, index) => { + const cached = previous.pages[index]; + return cached?.cursor === page.cursor && cached.result === page.result; + }) + ) + return previous.parsed; + const cache = previous?.cache ?? new WeakMap(); + const parsed = combinePullRequestDiffPages(pages, cache, scope, terminology); + snapshots.set(key, { pages, cache, parsed }); + return parsed; +} + /** A changed page invalidates later cursors, which belong to the previous patch. */ export function retainPullRequestDiffPage( pages: readonly PullRequestDiffPage[], diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx b/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx index 0548cbb241929..3000efb9633bf 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx @@ -3,14 +3,16 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import type { PullRequestDiffResult } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { Atom, AtomRegistry } from "effect/reactivity"; -import { act, useLayoutEffect } from "react"; +import { act, useLayoutEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ diff: vi.fn() })); vi.mock("../../state/pull-requests", () => ({ composerPullRequests: { diff: mocks.diff } })); -import { usePullRequestDiff } from "./usePullRequestDiff"; +import { usePullRequestDiff, usePullRequestDiffEntry } from "./usePullRequestDiff"; +import * as reviewModel from "../review/reviewModel"; +import { getCachedNativeReviewDiffData } from "../review/nativeReviewDiffAdapter"; function patch(path: string, nextCursor: string | null = null): PullRequestDiffResult { return { @@ -67,6 +69,15 @@ function setup() { ); return { requests, + remount() { + act(() => + root.render( + + + , + ), + ); + }, get current() { return current; }, @@ -87,6 +98,123 @@ function setup() { }; } +it("reuses the parsed snapshot when a new route reads the same query result", async () => { + using parse = vi.spyOn(reviewModel, "buildReviewParsedDiff"); + using screen = setup(); + await screen.succeed(0, patch("first.ts")); + const parsed = screen.current.parsed; + const parses = parse.mock.calls.length; + screen.remount(); + expect(screen.current.parsed).toBe(parsed); + expect(parse).toHaveBeenCalledTimes(parses); + expect(screen.requests).toHaveLength(1); +}); + +function setupEntry() { + vi.useFakeTimers(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const frames = new Map(); + let frameId = 0; + const requestFrame = vi + .spyOn(globalThis, "requestAnimationFrame") + .mockImplementation((callback) => { + frames.set(++frameId, callback); + return frameId; + }); + const cancelFrame = vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation((id) => { + if (id != null) frames.delete(id); + }); + const listeners = new Map void>(); + const navigation = { + addListener( + type: "transitionStart" | "transitionEnd", + listener: (event: { data: { closing: boolean } }) => void, + ) { + listeners.set(type, listener); + return () => { + listeners.delete(type); + }; + }, + }; + const events: string[] = []; + const prepare = vi.fn(() => { + events.push("prepare"); + return getCachedNativeReviewDiffData({ + parsedDiff: reviewModel.buildReviewParsedDiff( + patch("first.ts").patch + patch("neighbor.ts").patch, + "entry", + ), + }); + }); + function Body() { + const [data] = useState(prepare); + return
{data.files.map((file) => file.path).join(",")}
; + } + function Entry() { + const ready = usePullRequestDiffEntry(navigation); + useLayoutEffect(() => { + events.push(ready ? "body-commit" : "shell-commit"); + }, [ready]); + return ready ? :
Opening changes
; + } + const container = document.createElement("div"); + const root = createRoot(container); + act(() => root.render()); + return { + prepare, + events, + container, + frame() { + const pending = [...frames.values()]; + frames.clear(); + act(() => { + for (const callback of pending) callback(0); + }); + }, + event(type: "transitionStart" | "transitionEnd", closing = false) { + act(() => listeners.get(type)?.({ data: { closing } })); + }, + fallback() { + act(() => vi.runAllTimers()); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + requestFrame.mockRestore(); + cancelFrame.mockRestore(); + vi.useRealTimers(); + }, + }; +} + +it("commits the loading shell and waits for the opening transition before building the diff", () => { + using screen = setupEntry(); + expect(screen.events).toEqual(["shell-commit"]); + screen.event("transitionStart"); + screen.frame(); + screen.frame(); + screen.event("transitionEnd", true); + expect(screen.prepare).not.toHaveBeenCalled(); + screen.event("transitionEnd"); + expect(screen.events).toEqual(["shell-commit", "prepare", "body-commit"]); + expect(screen.container.textContent).toBe("first.ts,neighbor.ts"); +}); + +it.each(["unanimated", "missing-completion"] as const)( + "opens the diff after a %s presentation without stranding the shell", + (presentation) => { + using screen = setupEntry(); + if (presentation === "missing-completion") screen.event("transitionStart"); + screen.frame(); + expect(screen.prepare).not.toHaveBeenCalled(); + screen.frame(); + if (presentation === "missing-completion") { + expect(screen.prepare).not.toHaveBeenCalled(); + screen.fallback(); + } + expect(screen.prepare).toHaveBeenCalledOnce(); + }, +); + it("retries a failed continuation without discarding loaded files or restarting page one", async () => { using screen = setup(); await screen.succeed(0, patch("first.ts", "second")); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts index 2b33c86871ac8..c3c25b859193a 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts @@ -1,19 +1,13 @@ import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; -import { - EnvironmentId, - ProjectId, - type PullRequestDiffResult, - type PullRequestListEntry, -} from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, type PullRequestListEntry } from "@t3tools/contracts"; import { useAtomRefresh } from "@effect/atom-react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useState } from "react"; import { composerPullRequests } from "../../state/pull-requests"; import { useEnvironmentQuery } from "../../state/query"; import { usePullToRefresh } from "../../state/use-pull-to-refresh"; -import type { ReviewParsedDiff } from "../review/reviewModel"; import { - combinePullRequestDiffPages, + getCachedPullRequestDiffPages, retainPullRequestDiffPage, type PullRequestDiffPage, } from "./pullRequestDiffPages"; @@ -43,6 +37,46 @@ export function pullRequestTarget(params: PullRequestParams) { const NO_PAGES: readonly PullRequestDiffPage[] = []; +export function usePullRequestDiffEntry(navigation: { + readonly addListener: ( + type: "transitionStart" | "transitionEnd", + listener: (event: { readonly data: { readonly closing: boolean } }) => void, + ) => () => void; +}) { + const [ready, setReady] = useState(false); + useLayoutEffect(() => { + if (ready) return; + let opening = false; + let closing = false; + const finish = () => { + if (!closing) setReady(true); + }; + const removeStart = navigation.addListener("transitionStart", ({ data }) => { + closing = data.closing; + if (!closing) opening = true; + }); + const removeEnd = navigation.addListener("transitionEnd", ({ data }) => { + closing = data.closing; + if (!closing) finish(); + }); + // Initial and unanimated presentations may have no transition events; let the shell paint first. + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + if (!opening) finish(); + }); + }); + // A missing native completion must not strand the route on its loading state. + const fallback = setTimeout(finish, 1_000); + return () => { + removeStart(); + removeEnd(); + cancelAnimationFrame(frame); + clearTimeout(fallback); + }; + }, [navigation, ready]); + return ready; +} + export function usePullRequestDiff( params: PullRequestDiffParams, terminology: ChangeRequestTerminology, @@ -74,10 +108,9 @@ export function usePullRequestDiff( }, [cursor, key, query.data, state.key, state.pages]); if (state.key !== key || state.pages !== pages) setState({ key, cursor, pages }); - const [parseCache] = useState(() => new WeakMap()); const parsed = useMemo( - () => combinePullRequestDiffPages(pages, parseCache, key, terminology), - [key, pages, parseCache, terminology], + () => getCachedPullRequestDiffPages(pages, key, terminology), + [key, pages, terminology], ); const patch = useMemo(() => pages.map((page) => page.result.patch).join("\n"), [pages]); const nextCursor = pages.at(-1)?.result.nextCursor ?? null; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index a17347d8347f3..b3984ea105476 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3981,6 +3981,132 @@ it.effect("pauses uncached activity on a host after one activity read is rate li }), ); +it.effect("authorizes cold source reads with paginated paths without fetching patch hunks", () => + Effect.gen(function* () { + const pages: Array = []; + const contents: Array = []; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getDiff: () => Effect.die("Membership must not fetch patch hunks"), + getDiffFilePaths: (input) => + Effect.sync(() => { + pages.push(input.cursor); + return input.cursor === undefined + ? { files: [{ oldPath: "before.ts", newPath: "after.ts" }], nextCursor: "next" } + : { files: [{ oldPath: "gone.ts", newPath: "gone.ts" }], nextCursor: null }; + }), + getDiffFileContents: (input) => + Effect.sync(() => { + contents.push(`${input.oldPath}->${input.newPath}`); + return { oldContents: "before", newContents: "after" }; + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + yield* service.diffFileContents({ + ...ref, + changeType: "rename-changed", + oldPath: "before.ts", + newPath: "after.ts", + }); + yield* service.diffFileContents({ + ...ref, + changeType: "deleted", + oldPath: "gone.ts", + newPath: "gone.ts", + }); + for (const [oldPath, newPath] of [ + [".env", ".env"], + ["../after.ts", "../after.ts"], + ["secret.ts", "after.ts"], + ] as const) { + const error = yield* service + .diffFileContents({ ...ref, changeType: "change", oldPath, newPath }) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "not-found"); + } + assert.deepStrictEqual(pages, [undefined, "next"]); + assert.deepStrictEqual(contents, ["before.ts->after.ts", "gone.ts->gone.ts"]); + }), +); + +it.effect.each(["commit", "credential", "expiry", "refresh"] as const)( + "keeps cold metadata membership in its original scope through an in-flight %s", + (boundary) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let credential = "broad"; + let pathReads = 0; + let contentReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + withVerifiedCredential: (_, use) => + Effect.suspend(() => + use({ accountId: "101", viewer: "octocat", credentialFingerprint: credential }), + ), + getDiff: () => Effect.die("Membership must not fetch patch hunks"), + getDiffFilePaths: (input) => + Effect.gen(function* () { + assert.strictEqual(input.cwd, "/a"); + assert.strictEqual(input.host, "github.com"); + assert.strictEqual(input.repository, "acme/web"); + if (++pathReads !== 1) return { files: [], nextCursor: null }; + assert.strictEqual( + input.commit, + boundary === "commit" ? "a".repeat(40) : undefined, + ); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { + files: [{ oldPath: "private.ts", newPath: "private.ts" }], + nextCursor: null, + }; + }), + getDiffFileContents: () => + Effect.sync(() => { + contentReads += 1; + return { oldContents: "old", newContents: "new" }; + }), + }), + ], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + host: "github.com", + expectedAccountId: "101", + changeType: "change" as const, + oldPath: "private.ts", + newPath: "private.ts", + }; + const initial = boundary === "commit" ? { ...input, commit: "a".repeat(40) } : input; + const loading = yield* Effect.forkChild( + service.withRoutingCredential(initial, service.diffFileContents(initial)), + ); + yield* Deferred.await(started); + if (boundary === "expiry") yield* TestClock.adjust("61 seconds"); + if (boundary === "refresh") yield* service.invalidate({ reference: input }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(loading); + if (boundary === "credential") credential = "restricted"; + const error = yield* service + .withRoutingCredential(input, service.diffFileContents(input)) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "not-found"); + assert.strictEqual(pathReads, 2); + assert.strictEqual(contentReads, 1); + }), +); + it.effect( "reuses loaded oversized diff slices to expand only files the change request changes", () => diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 437f8ab4e52dd..b37cf7d57667f 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -86,6 +86,7 @@ import type { SourceControlProviderContext } from "@t3tools/source-control-core/ import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { type ProviderChangeRequest, + type ProviderDiffFilePaths, type ProviderFilterOptionPage, type ProviderListCursor, type ProviderChangeRequestWatchFingerprint, @@ -543,6 +544,9 @@ function withRateLimitBackoff( }), getViewerPermissions: interactive("getViewerPermissions", api.getViewerPermissions), getDiff: wrap("getDiff", api.getDiff), + ...(api.getDiffFilePaths === undefined + ? {} + : { getDiffFilePaths: wrap("getDiffFilePaths", api.getDiffFilePaths) }), ...(api.getDiffFileContents === undefined ? {} : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }), @@ -3475,14 +3479,10 @@ export const make = Effect.gen(function* () { const fileKey = (oldPath: string, newPath: string) => JSON.stringify([oldPath, newPath]); const sliceKey = (input: PullRequestDiffInput) => JSON.stringify([refCacheKey(input), input.cursor ?? null, input.commit ?? null]); - const record = (key: string, slice: PullRequestDiffResult, at: number) => { + const recordPaths = (key: string, slice: ProviderDiffFilePaths, at: number) => { const entry = { at, - files: new Set( - parseDiffFilePaths(slice.patch).map((file) => - fileKey(file.oldPath ?? file.newPath ?? "", file.newPath ?? file.oldPath ?? ""), - ), - ), + files: new Set(slice.files.map((file) => fileKey(file.oldPath, file.newPath))), nextCursor: slice.nextCursor, }; held.delete(key); @@ -3493,11 +3493,37 @@ export const make = Effect.gen(function* () { held.set(key, entry); return entry; }; + const record = (key: string, slice: PullRequestDiffResult, at: number) => + recordPaths( + key, + { + files: parseDiffFilePaths(slice.patch).map((file) => ({ + oldPath: file.oldPath ?? file.newPath ?? "", + newPath: file.newPath ?? file.oldPath ?? "", + })), + nextCursor: slice.nextCursor, + }, + at, + ); const read = Effect.fn("PullRequestService.read")(function* (input: PullRequestDiffInput) { const key = sliceKey(input); const now = yield* Clock.currentTimeMillis; const cached = held.get(key); if (cached !== undefined && now - cached.at <= ttlMs) return cached; + const project = yield* requireProject(input); + if (project.api.capabilities.diff && project.api.getDiffFilePaths) { + const slice = yield* project.api + .getDiffFilePaths({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe(Effect.mapError(toPullRequestError("diffFileContents"))); + return recordPaths(key, slice, now); + } const slice = yield* diff(input); return record(key, slice, now); }); diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index ae64bda0395f3..ce1a72c1aedf6 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -223,6 +223,11 @@ export interface ProviderDiffFileContents { readonly newContents: string; } +export interface ProviderDiffFilePaths { + readonly files: ReadonlyArray<{ readonly oldPath: string; readonly newPath: string }>; + readonly nextCursor: string | null; +} + export interface ProviderFilesViewed { readonly files: ReadonlyArray; /** More files exist; missing entries are unknown, not "unviewed". */ @@ -394,6 +399,15 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** Exact changed path pairs, including renames, without fetching patch contents. */ + readonly getDiffFilePaths?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly cursor?: string | undefined; + readonly commit?: string | undefined; + }, + ) => Effect.Effect; + readonly getDiffFileContents?: ( input: ProviderRepositoryRef & { readonly number: number; diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts index 40430ab2f1521..d787b2ba62ac8 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -164,6 +164,124 @@ const assertRequestId = (body: unknown) => /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, ); +describe("GitCafePullRequestProvider changed paths", () => { + const snapshot = { version: pull.version, headOid, comparisonBaseOid: baseOid }; + + it.effect("pages changed paths without fetching diff-file hunks", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": (request: SentRequest) => ({ + ...snapshot, + items: request.query.has("after") + ? [{ path: "gone.ts", status: "deleted" }] + : [{ path: "new name.ts", oldPath: "old name.ts", status: "renamed" }], + next: request.query.has("after") ? null : "next-page", + }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const first = yield* provider.getDiffFilePaths!(target); + const second = yield* provider.getDiffFilePaths!({ ...target, cursor: first.nextCursor! }); + assert.deepStrictEqual(first.files, [{ oldPath: "old name.ts", newPath: "new name.ts" }]); + assert.deepStrictEqual(second, { + files: [{ oldPath: "gone.ts", newPath: "gone.ts" }], + nextCursor: null, + }); + assert.isFalse(server.sent.some((request) => request.path.includes("/diff-file"))); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each(["getDiff", "getDiffFilePaths"] as const)( + "%s rejects a repeated continuation cursor", + (method) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [], next: "same-page" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const first = yield* provider[method]!(target); + const input = { ...target, cursor: first.nextCursor! }; + const result = + method === "getDiff" + ? yield* provider.getDiff(input).pipe(Effect.result) + : yield* provider.getDiffFilePaths!(input).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + }).pipe(Effect.provide(server.layer)); + }, + ); + + // getDiff cases guard parity with the existing patch path; getDiffFilePaths must refuse the same pages. + it.effect.each( + (["getDiff", "getDiffFilePaths"] as const).flatMap((method) => + [{ version: 5 }, { headOid: baseOid }, { comparisonBaseOid: headOid }].map((changed) => ({ + method, + changed, + })), + ), + )( + "$method refuses a continuation from a different revision ($changed)", + ({ method, changed }) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": (request: SentRequest) => ({ + ...snapshot, + ...(request.query.has("after") ? changed : {}), + items: [], + next: request.query.has("after") ? null : "next-page", + }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const read = (cursor?: string) => + method === "getDiff" + ? provider.getDiff({ ...target, ...(cursor === undefined ? {} : { cursor }) }) + : provider.getDiffFilePaths!({ + ...target, + ...(cursor === undefined ? {} : { cursor }), + }); + const first = yield* read(); + const input = { ...target, cursor: first.nextCursor! }; + const result = + method === "getDiff" + ? yield* provider.getDiff(input).pipe(Effect.result) + : yield* provider.getDiffFilePaths!(input).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect.each(["getDiff", "getDiffFilePaths"] as const)( + "%s reads a pull that reports no head yet", + (method) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, headOid: null }, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider[method]!(target); + assert.isNull(result.nextCursor); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect.each([{ version: 5 }, { headOid: baseOid }])( + "refuses initial changed paths from a different pull revision (%j)", + (changed) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, ...changed, items: [], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFilePaths!(target).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + }).pipe(Effect.provide(server.layer)); + }, + ); +}); + describe("GitCafePullRequestProvider source contents", () => { const comparisonBase = "a".repeat(40); const snapshot = { version: pull.version, headOid, comparisonBaseOid: comparisonBase }; diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 30e9296d36e51..f2aa82fdcb661 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -56,6 +56,8 @@ const AUTHOR_DISCOVERY_PAGES = 5; const KNOWN_AUTHORS_LIMIT = 10_000; /** GitCafe's cap on paths per `/diff-files` read, and so one diff page. */ const DIFF_FILES_BATCH = 64; +/** Changed-path pages carry no hunks, so they can be read in larger pages. */ +const CHANGES_PAGE_LIMIT = 500; /** Line totals require hunk reads; omit them for large PRs. */ const LINE_STATS_MAX_FILES = DIFF_FILES_BATCH * 4; const MAX_CHANGE_PAGES = 20; @@ -522,6 +524,45 @@ export const make = Effect.gen(function* () { return { ...stack, members }; }); + const readDiffPage = Effect.fn("GitCafePullRequestProvider.readDiffPage")(function* ( + operation: string, + input: Parameters[0], + limit: number, + ) { + if (input.commit !== undefined) + return yield* failure(operation, "GitCafe commit diffs are not supported yet."); + const cursor = + input.cursor === undefined + ? null + : yield* decodeDiffCursor(input.cursor).pipe( + Effect.mapError((cause) => failure(operation, "Invalid GitCafe diff cursor.", cause)), + ); + // A pull without a head yet has nothing to pin; its version still is. + const expected = cursor ?? (yield* readPull(operation, input)); + const version = expected.version; + const path = yield* pullPath(operation, input); + const query = new URLSearchParams({ expectedVersion: String(version), limit: String(limit) }); + if (cursor !== null) query.set("after", cursor.after); + const page = yield* read(operation, input, { path: `${path}/changes?${query}` }, Changes); + if ( + page.version !== version || + (expected.headOid !== null && page.headOid !== expected.headOid) || + (cursor !== null && cursor.baseOid !== page.comparisonBaseOid) + ) + return yield* failure( + operation, + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + if (cursor !== null && page.next !== null && page.next === cursor.after) + return yield* failure(operation, "GitCafe returned a repeated diff page cursor."); + const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid }; + return { + items: page.items, + revision, + nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), + }; + }); + const readDiffFiles = Effect.fn("GitCafePullRequestProvider.readDiffFiles")(function* ( input: PullRef, revision: { readonly version: number; readonly headOid: string; readonly baseOid: string }, @@ -605,7 +646,10 @@ export const make = Effect.gen(function* () { let revision: { version: number; headOid: string; baseOid: string } | undefined; let after: string | null = null; for (let page = 0; page < MAX_CHANGE_PAGES; page++) { - const query = new URLSearchParams({ expectedVersion: String(version), limit: "500" }); + const query = new URLSearchParams({ + expectedVersion: String(version), + limit: String(CHANGES_PAGE_LIMIT), + }); if (after !== null) query.set("after", after); const batch: typeof Changes.Type = yield* read( "getChangeRequest", @@ -966,36 +1010,21 @@ export const make = Effect.gen(function* () { getViewerPermissions: (input) => readPull("getViewerPermissions", input).pipe(Effect.map(Json.toViewerPermissions)), getDiff: Effect.fn("GitCafePullRequestProvider.getDiff")(function* (input) { - if (input.commit !== undefined) - return yield* failure("getDiff", "GitCafe commit diffs are not supported yet."); - const cursor = - input.cursor === undefined - ? null - : yield* decodeDiffCursor(input.cursor).pipe( - Effect.mapError((cause) => failure("getDiff", "Invalid GitCafe diff cursor.", cause)), - ); - const version = cursor?.version ?? (yield* readPull("getDiff", input)).version; - const path = yield* pullPath("getDiff", input); - const query = new URLSearchParams({ - expectedVersion: String(version), - limit: String(DIFF_FILES_BATCH), - }); - if (cursor !== null) query.set("after", cursor.after); - const page = yield* read("getDiff", input, { path: `${path}/changes?${query}` }, Changes); - if ( - page.version !== version || - (cursor !== null && - (cursor.headOid !== page.headOid || cursor.baseOid !== page.comparisonBaseOid)) - ) - return yield* failure( - "getDiff", - "GitCafe answered from a different revision of the pull request. Refresh the diff.", - ); - const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid }; - const details = yield* readDiffFiles(input, revision, detailablePaths(page.items)); + const page = yield* readDiffPage("getDiff", input, DIFF_FILES_BATCH); + const details = yield* readDiffFiles(input, page.revision, detailablePaths(page.items)); return { ...Json.toDiff({ items: withDetails(page.items, details) }), - nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), + nextCursor: page.nextCursor, + }; + }), + getDiffFilePaths: Effect.fn("GitCafePullRequestProvider.getDiffFilePaths")(function* (input) { + const page = yield* readDiffPage("getDiffFilePaths", input, CHANGES_PAGE_LIMIT); + return { + files: page.items.map((file) => ({ + oldPath: file.oldPath ?? file.path, + newPath: file.path, + })), + nextCursor: page.nextCursor, }; }), getDiffFileContents: Effect.fn("GitCafePullRequestProvider.getDiffFileContents")( From 17dc1afa15980c72a790271034e3cb13ccf810c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:53:32 -0700 Subject: [PATCH 008/222] perf(client): skip unchanged shell and config cache writes (#24266) --- .../client-runtime/src/state/server.test.ts | 258 ++++++++++++++++++ packages/client-runtime/src/state/server.ts | 50 +++- .../src/state/shell-sync.test.ts | 150 ++++++++++ packages/client-runtime/src/state/shell.ts | 13 + .../client-runtime/src/state/shellReducer.ts | 38 +-- 5 files changed, 488 insertions(+), 21 deletions(-) diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 931884a92c1bc..09b28c539d33f 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -1,5 +1,8 @@ import { EnvironmentId, + ServerObservability, + EnvironmentThemeId, + UsageLimitSourceId, type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleWelcomePayload, @@ -15,8 +18,10 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { RpcClientError } from "effect/rpc"; import * as Socket from "effect/socket/Socket"; @@ -207,7 +212,260 @@ describe("update restart reconnect nudges", () => { ); }); +const makeConfigPersistenceHarness = Effect.fn("test.makeConfigPersistenceHarness")(function* ( + saveServerConfig: Persistence.EnvironmentCacheStore["Service"]["saveServerConfig"], +) { + const events = yield* Queue.unbounded(); + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedSome(CONFIG), + saveServerConfig, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const state = yield* makeEnvironmentServerConfigState({ + environmentThemes: true, + usageLimitSources: true, + }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + const send = Effect.fn("test.sendConfigEvent")(function* (event: ServerConfigStreamEvent) { + yield* Queue.offer(events, event); + return yield* SubscriptionRef.changes(state).pipe( + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.filter((projection) => projection.latestEvent === event), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + }); + return { send }; +}); + describe("server state projection", () => { + it.effect("skips duplicate keybinding saves but persists changed keybindings", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const keybindingsEvent = (key: string): ServerConfigStreamEvent => ({ + version: 1, + type: "keybindingsUpdated", + payload: { + keybindings: [ + { + command: "terminal.toggle", + shortcut: { + key, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + ], + issues: [], + }, + }); + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makeConfigPersistenceHarness((_id, config) => + Ref.update(saved, (values) => [...values, config]), + ); + const first = yield* h.send(keybindingsEvent("p")); + yield* TestClock.adjust("500 millis"); + expect(yield* Ref.get(saved)).toEqual([first.config]); + + yield* h.send(keybindingsEvent("p")); + yield* TestClock.adjust("10 seconds"); + expect(yield* Ref.get(saved)).toEqual([first.config]); + + const changed = yield* h.send(keybindingsEvent("q")); + yield* TestClock.adjust("10 seconds"); + expect(yield* Ref.get(saved)).toEqual([first.config, changed.config]); + }), + ); + }), + ); + + it.effect( + "compares nested durable values independently of key order and omitted undefined fields", + () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const observability = { + logsDirectoryPath: "/synthetic/logs", + localTracingEnabled: false, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + otlpLogsEnabled: false, + }; + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makeConfigPersistenceHarness((_id, config) => + Ref.update(saved, (values) => [...values, config]), + ); + const first = yield* h.send(snapshotEvent({ ...CONFIG, observability })); + yield* TestClock.adjust("500 millis"); + const reordered = Object.fromEntries( + Object.entries(observability).toReversed(), + ) as typeof observability; + for (const next of [ + { ...CONFIG, observability: reordered }, + { ...CONFIG, observability: { ...reordered, otlpTracesUrl: undefined } }, + { ...CONFIG, observability: { ...observability } }, + ]) { + const encode = Schema.encodeEffect(Schema.fromJsonString(ServerObservability)); + expect(JSON.parse(yield* encode(next.observability))).toEqual( + JSON.parse(yield* encode(observability)), + ); + const projection = yield* h.send(snapshotEvent(next)); + expect(projection.config.observability).toEqual(next.observability); + yield* TestClock.adjust("10 seconds"); + expect.soft(yield* Ref.get(saved)).toEqual([first.config]); + } + const writesBeforeChange = (yield* Ref.get(saved)).length; + const changed = yield* h.send( + snapshotEvent({ + ...CONFIG, + observability: { ...observability, otlpTracesUrl: "https://traces.example.test" }, + }), + ); + yield* TestClock.adjust("10 seconds"); + expect((yield* Ref.get(saved)).length).toBe(writesBeforeChange + 1); + expect((yield* Ref.get(saved)).at(-1)).toEqual(changed.config); + }), + ); + }), + ); + + it.effect("saves once across transient themes, usage sources and stall updates", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makeConfigPersistenceHarness((_id, config) => + Ref.update(saved, (values) => [...values, config]), + ); + yield* h.send(snapshotEvent(CONFIG)); + yield* TestClock.adjust("500 millis"); + expect((yield* Ref.get(saved)).length).toBe(1); + for (const event of [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { + themes: [ + { + id: EnvironmentThemeId.make("theme-test"), + name: "Test theme", + appearance: "dark", + }, + ], + }, + }, + { + version: 1, + type: "usageLimitSourcesUpdated", + payload: { + sources: [ + { + id: UsageLimitSourceId.make("source-test"), + kind: "cliproxy", + label: "Test source", + checkedAt: "2026-10-20T07:50:37.633Z", + accounts: [], + }, + ], + }, + }, + snapshotEvent({ + ...CONFIG, + lastEventLoopStall: { + detectedAt: "2026-10-20T07:50:37.633Z", + durationMs: 1000, + loadAverage: 1, + cpuCount: 8, + }, + }), + ] satisfies ReadonlyArray) { + const projection = yield* h.send(event); + expect(projection.latestEvent).toBe(event); + if (event.type === "environmentThemesUpdated") + expect(projection.config.environmentThemes).toEqual(event.payload.themes); + if (event.type === "usageLimitSourcesUpdated") + expect(projection.config.usageLimitSources).toEqual(event.payload.sources); + yield* TestClock.adjust("10 seconds"); + } + }), + ); + expect(yield* Ref.get(saved)).toEqual([CONFIG]); + }), + ); + + it.effect("flushes pending durable changes after a transient event", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const changed = { ...CONFIG, keybindingsConfigPath: "/changed/keybindings.json" }; + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makeConfigPersistenceHarness((_id, config) => + Ref.update(saved, (values) => [...values, config]), + ); + yield* h.send(snapshotEvent(CONFIG)); + yield* TestClock.adjust("500 millis"); + yield* h.send(snapshotEvent(changed)); + yield* h.send({ version: 1, type: "environmentThemesUpdated", payload: { themes: [] } }); + }), + ); + expect(yield* Ref.get(saved)).toEqual([CONFIG, changed]); + }), + ); + + it.effect("retries a failed durable save on a transient event", () => + Effect.gen(function* () { + const attempts = yield* Ref.make>([]); + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makeConfigPersistenceHarness((_id, config) => + Effect.gen(function* () { + const values = yield* Ref.updateAndGet(attempts, (values) => [...values, config]); + if (values.length === 1) + return yield* new Persistence.ConnectionPersistenceError({ + operation: "save-server-config", + message: "test failure", + }); + }), + ); + yield* h.send(snapshotEvent(CONFIG)); + yield* TestClock.adjust("500 millis"); + yield* h.send({ version: 1, type: "usageLimitSourcesUpdated", payload: { sources: [] } }); + yield* TestClock.adjust("10 seconds"); + }), + ); + expect(yield* Ref.get(attempts)).toEqual([CONFIG, CONFIG]); + }), + ); + it("only treats a legacy transport interruption as an unacknowledged handoff", () => { expect(isLegacyUpdateHandoffLoss(Cause.interrupt(1))).toBe(true); expect( diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 77c5beb61a402..7913f8fe84e22 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -358,6 +358,41 @@ export interface ServerConfigSubscriptionOptions { readonly usageLimitsCommand?: boolean; } +// Configs are JSON-shaped. Missing and undefined object fields persist identically. +function sameCacheValue(previous: unknown, next: unknown): boolean { + if (previous === next) return true; + if ( + previous === null || + next === null || + typeof previous !== "object" || + typeof next !== "object" + ) { + return false; + } + if (Array.isArray(previous) || Array.isArray(next)) { + if (!Array.isArray(previous) || !Array.isArray(next) || previous.length !== next.length) { + return false; + } + for (let index = 0; index < previous.length; index += 1) { + if (!sameCacheValue(previous[index], next[index])) return false; + } + return true; + } + if ( + Object.getPrototypeOf(previous) !== Object.prototype || + Object.getPrototypeOf(next) !== Object.prototype + ) { + return false; + } + const before = previous as Record; + const after = next as Record; + return ( + Object.keys(before).every((key) => + sameCacheValue(before[key], Object.hasOwn(after, key) ? after[key] : undefined), + ) && Object.keys(after).every((key) => Object.hasOwn(before, key) || after[key] === undefined) + ); +} + export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( function* (subscription: ServerConfigSubscriptionOptions) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; @@ -383,13 +418,22 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf ); const persistence = yield* Queue.sliding(1); const pendingPersistence = yield* Ref.make>(Option.none()); + let lastPersisted: ServerConfig | undefined; const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* ( config: ServerConfig, ) { // A cached stall would read as a fresh freeze on the next launch. - const { lastEventLoopStall: _stall, ...cacheable } = withoutEnvironmentThemes(config); - return yield* cache.saveServerConfig(environmentId, cacheable).pipe( + const { + environmentThemes: _themes, + usageLimitSources: _sources, + lastEventLoopStall: _stall, + ...cacheable + } = config; + if (lastPersisted !== undefined && sameCacheValue(lastPersisted, cacheable)) { + return true; + } + const saved = yield* cache.saveServerConfig(environmentId, cacheable).pipe( Effect.as(true), Effect.catch((error) => Effect.logWarning("Could not persist cached server configuration.").pipe( @@ -401,6 +445,8 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf ), ), ); + if (saved) lastPersisted = cacheable; + return saved; }); const persistPending = Effect.fn("EnvironmentServerConfigState.persistPending")(function* ( diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 0e76e321bf3ae..56476f580b41f 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -17,6 +17,7 @@ import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { AVAILABLE_CONNECTION_STATE, @@ -63,7 +64,156 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { }; } +const makePersistenceHarness = Effect.fn("test.makeShellPersistenceHarness")(function* ( + saveShell: Persistence.EnvironmentCacheStore["Service"]["saveShell"], +) { + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const state = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, { + load: () => Effect.succeedNone, + }), + ); + const send = Effect.fn("test.sendShellItem")(function* ( + item: OrchestrationV2ShellStreamItem, + ready: (snapshot: OrchestrationV2ShellSnapshot) => boolean, + ) { + const previous = yield* SubscriptionRef.get(state); + yield* Queue.offer(events, item); + return yield* SubscriptionRef.changes(state).pipe( + Stream.filter((value) => value !== previous), + Stream.map((value) => value.snapshot), + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.filter(ready), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + }); + yield* send({ kind: "snapshot", snapshot: LIVE_SHELL_SNAPSHOT }, () => true); + return { state, send }; +}); + describe("environment shell synchronization", () => { + it.effect("skips unchanged shell writes, persists resets and flushes changed rows", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makePersistenceHarness((_id, snapshot) => + Ref.update(saved, (values) => [...values, snapshot]), + ); + yield* TestClock.adjust("500 millis"); + yield* h.send( + { kind: "thread.updated", sequence: 10, location: "active", thread: v2ThreadShell }, + (snapshot) => snapshot.snapshotSequence === 10, + ); + yield* TestClock.adjust("10 seconds"); + expect((yield* Ref.get(saved)).map((snapshot) => snapshot.snapshotSequence)).toEqual([1]); + const live = Option.getOrThrow((yield* SubscriptionRef.get(h.state)).snapshot); + expect(live.snapshotSequence).toBe(10); + const enriched = yield* h.send( + { + kind: "snapshot", + snapshot: { ...live, projects: live.projects.map((project) => ({ ...project })) }, + resolvedRepositoryIdentityRoots: live.projects.map( + (project) => project.workspaceRoot, + ), + }, + () => true, + ); + expect(enriched.projects).toEqual(live.projects); + expect(enriched.snapshotSequence).toBe(10); + yield* TestClock.adjust("10 seconds"); + expect((yield* Ref.get(saved)).map((snapshot) => snapshot.snapshotSequence)).toEqual([1]); + yield* h.send( + { kind: "snapshot", snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 4 } }, + (snapshot) => snapshot.snapshotSequence === 4, + ); + yield* TestClock.adjust("10 seconds"); + yield* h.send( + { + kind: "snapshot", + snapshot: { + ...LIVE_SHELL_SNAPSHOT, + snapshotSequence: 4, + projects: [{ ...v2Project, title: "Changed at same sequence" }], + }, + }, + (snapshot) => snapshot.projects[0]?.title === "Changed at same sequence", + ); + }), + ); + expect( + (yield* Ref.get(saved)).map((snapshot) => [ + snapshot.snapshotSequence, + snapshot.projects[0]?.title, + ]), + ).toEqual([ + [1, v2Project.title], + [4, v2Project.title], + [4, "Changed at same sequence"], + ]); + }), + ); + + it.effect("retries a failed shell save after a cursor-only update", () => + Effect.gen(function* () { + const attempts = yield* Ref.make>([]); + yield* Effect.scoped( + Effect.gen(function* () { + const h = yield* makePersistenceHarness((_id, snapshot) => + Effect.gen(function* () { + const values = yield* Ref.updateAndGet(attempts, (values) => [ + ...values, + snapshot.snapshotSequence, + ]); + if (values.length === 1) + return yield* new Persistence.ConnectionPersistenceError({ + operation: "save-shell", + message: "test failure", + }); + }), + ); + yield* TestClock.adjust("500 millis"); + yield* h.send( + { kind: "thread.updated", sequence: 2, location: "active", thread: v2ThreadShell }, + (snapshot) => snapshot.snapshotSequence === 2, + ); + yield* TestClock.adjust("10 seconds"); + }), + ); + expect(yield* Ref.get(attempts)).toEqual([1, 2]); + }), + ); + it.effect("flushes the latest live snapshot without blocking connection state updates", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index a01465d2dc7e5..2c51e3a1a3178 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -125,6 +125,19 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") if (Option.isNone(latest) || latest.value === lastPersisted) return; if (latest.value.threads.some((thread) => pendingLinkRows.has(thread))) return; const snapshot = latest.value; + if ( + lastPersisted !== undefined && + snapshot.snapshotSequence >= lastPersisted.snapshotSequence && + snapshot.schemaVersion === lastPersisted.schemaVersion && + snapshot.projects === lastPersisted.projects && + snapshot.threads === lastPersisted.threads && + snapshot.archivedThreads === lastPersisted.archivedThreads + ) { + // Keep the live cursor without rewriting the rows. Remember it here too so a + // later authoritative reset to a lower sequence still reaches the cache. + lastPersisted = snapshot; + return; + } const saved = yield* cache.saveShell(environmentId, snapshot).pipe( Effect.as(true), Effect.catch((error) => diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index eec7f6c949fce..2d9647e85fc1c 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -92,27 +92,27 @@ export function mergeShellSnapshotProjects( if (isEnrichment) { const nextById = new Map(next.projects.map((project) => [project.id, project] as const)); - return { - ...previous, - projects: previous.projects.map((project) => { - const candidate = nextById.get(project.id); - if (candidate === undefined || candidate.workspaceRoot !== project.workspaceRoot) { + const projects = previous.projects.map((project) => { + const candidate = nextById.get(project.id); + if (candidate === undefined || candidate.workspaceRoot !== project.workspaceRoot) { + return project; + } + if (resolvedRootSet?.has(project.workspaceRoot) === true) { + // Refreshes re-resolve every root; an unchanged identity keeps the project's identity + // so every subscriber (each pull request row, the sidebar) skips re-rendering. + if (sameRepositoryIdentity(project.repositoryIdentity, candidate.repositoryIdentity)) { return project; } - if (resolvedRootSet?.has(project.workspaceRoot) === true) { - // Refreshes re-resolve every root; an unchanged identity keeps the project's identity - // so every subscriber (each pull request row, the sidebar) skips re-rendering. - if (sameRepositoryIdentity(project.repositoryIdentity, candidate.repositoryIdentity)) { - return project; - } - return { ...project, repositoryIdentity: candidate.repositoryIdentity }; - } - if (project.repositoryIdentity == null && candidate.repositoryIdentity != null) { - return { ...project, repositoryIdentity: candidate.repositoryIdentity }; - } - return project; - }), - }; + return { ...project, repositoryIdentity: candidate.repositoryIdentity }; + } + if (project.repositoryIdentity == null && candidate.repositoryIdentity != null) { + return { ...project, repositoryIdentity: candidate.repositoryIdentity }; + } + return project; + }); + return projects.every((project, index) => project === previous.projects[index]) + ? previous + : { ...previous, projects }; } const previousById = new Map(previous.projects.map((project) => [project.id, project] as const)); From 3e20b652e2dc84bedaaeb22089886ed45756fce6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 00:55:53 -0700 Subject: [PATCH 009/222] fix(mobile): remove perpetual reconnect indicator animation (#24273) --- .../connection/ConnectionStatusDot.tsx | 56 +++---------------- 1 file changed, 8 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx index 3067296c166db..e2d6bc5e3b99f 100644 --- a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx +++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx @@ -1,13 +1,4 @@ -import { useEffect } from "react"; import { View } from "react-native"; -import Animated, { - cancelAnimation, - Easing, - useAnimatedStyle, - useSharedValue, - withRepeat, - withTiming, -} from "react-native-reanimated"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -50,49 +41,17 @@ function statusDotTone( } } -function usePulseAnimation(pulse: boolean) { - const pulseProgress = useSharedValue(0); - - useEffect(() => { - if (pulse) { - pulseProgress.value = withRepeat( - withTiming(1, { - duration: 1100, - easing: Easing.out(Easing.cubic), - }), - -1, - false, - ); - return; - } - - cancelAnimation(pulseProgress); - pulseProgress.value = withTiming(0, { - duration: 180, - easing: Easing.out(Easing.quad), - }); - }, [pulse, pulseProgress]); - - return pulseProgress; -} - export function ConnectionStatusDot(props: { readonly state: ConnectionStatusDotState; readonly pulse: boolean; readonly size?: number; }) { - const pulseProgress = usePulseAnimation(props.pulse); const { themeAppearance, themeVariables } = useAppearancePreferences(); const tone = statusDotTone(props.state, themeVariables, themeAppearance === "dark"); const dotSize = props.size ?? 10; const haloSize = dotSize + 4; const containerSize = haloSize + 4; - const haloStyle = useAnimatedStyle(() => ({ - opacity: props.pulse ? 0.14 + (1 - pulseProgress.value) * 0.3 : 0, - transform: [{ scale: 0.78 + pulseProgress.value * 1.16 }], - })); - return ( - + opacity: 0.3, + }} + /> + ) : null} Date: Sun, 25 Oct 2026 00:57:47 -0700 Subject: [PATCH 010/222] feat(mobile): close the keyboard from the Duo's side bar (#24696) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/native/V5StackHeader.ios.tsx | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx index 507beb3fe1afe..7fc128f52ecbb 100644 --- a/apps/mobile/src/native/V5StackHeader.ios.tsx +++ b/apps/mobile/src/native/V5StackHeader.ios.tsx @@ -5,6 +5,7 @@ import type { } from "@react-navigation/native-stack"; import { isValidElement, useEffect, useRef } from "react"; import { StyleSheet } from "react-native"; +import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import { isDuoDevice } from "../lib/device-type"; import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; import { useDeviceType, useNativeLayoutMetrics } from "../native/native-layout-metrics"; @@ -153,6 +154,7 @@ export function V5StackHeader(props: { const theme = useMobileNavigationTheme(); const nativeMetrics = useNativeLayoutMetrics(); const deviceType = useDeviceType(); + const keyboardVisible = useKeyboardState((state) => state.isVisible); const searchRef = useRef(null); const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); @@ -231,6 +233,28 @@ export function V5StackHeader(props: { trailing.push({ type: "spacer", id: "compose-group", sizing: "fixed", width: 8 }); trailing.push(compose); } + // The vertical bar has no keyboard accessory row, so it carries its own dismiss. A regular-width + // primary column draws a horizontal bar beside the detail column's vertical one. The first + // trailing item sits at the bar's far end; the flexible space keeps it next to the keyboard. + if ( + keyboardVisible && + nativeMetrics && + nativeMetrics.verticalBarEdge !== "none" && + (props.column !== "primary" || nativeMetrics.horizontalSizeClass === "compact") + ) { + trailing.unshift( + { + type: "item", + id: "dismiss-keyboard", + title: "Dismiss keyboard", + icon: { type: "sfSymbol", name: "keyboard.chevron.compact.down" }, + onPress: () => void KeyboardController.dismiss(), + // The folded bar is short; keep this out of the overflow menu. + visibilityPriority: "high", + }, + { type: "spacer", id: "dismiss-keyboard-space", sizing: "flexible" }, + ); + } const left = options.headerLeft?.(itemProps); const right = options.headerRight?.(itemProps); if (!options.unstable_headerLeftItems && isValidElement(left)) From cfdfc2ab7c7afbc90ab36d9443d7806675e2b4f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 01:00:50 -0700 Subject: [PATCH 011/222] fix(mobile): dispatch native toolbar actions to current handlers (#24271) --- .../src/native/NativeHeaderToolbar.test.tsx | 163 ++++++++++++++---- .../mobile/src/native/NativeHeaderToolbar.tsx | 55 ++++-- 2 files changed, 163 insertions(+), 55 deletions(-) diff --git a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx index 2b3275de637d9..b531e8f1a7a4f 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx @@ -1,5 +1,5 @@ import type { NativeStackHeaderItem } from "@react-navigation/native-stack"; -import { act, createContext, type ReactElement } from "react"; +import { act, createContext, startTransition, Suspense, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, expect, it, vi } from "vite-plus/test"; @@ -14,7 +14,6 @@ const harness = vi.hoisted(() => ({ appliedOptions: {} as HeaderOptions, renderedItems: [] as NativeStackHeaderItem[], setOptionsCalls: 0, - openedDocuments: [] as string[], })); // Native headers retain the actions materialized when navigation applies options. Like React @@ -51,26 +50,6 @@ vi.mock("@react-navigation/native", async () => { import { NativeHeaderToolbar } from "./NativeHeaderToolbar"; let root: Root | null = null; -type Placement = "left" | "right" | "bottom"; - -function DocumentToolbar(props: { readonly path: string; readonly placement: Placement }) { - return ( - - - harness.openedDocuments.push(props.path)}> - Open PDF - - - - ); -} - -function renderDocument(path: string, placement: Placement) { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - harness.params = { path: path.split("/") }; - root ??= createRoot(document.createElement("div")); - act(() => root!.render()); -} // Like React Navigation's NavigationStateContext, whose default value throws when // read. React restores a context's default outside render, so layout effects see it. @@ -109,33 +88,76 @@ function renderedCustomLabel() { return (item.element as ReactElement<{ "aria-label": string }>).props["aria-label"]; } -function openRenderedDocument() { - const menu = harness.renderedItems[0]; - if (menu?.type !== "menu") throw new Error("Expected the native file menu"); - const action = menu.menu.items[0]; - if (action?.type !== "action") throw new Error("Expected the native Open PDF action"); - action.onPress?.(); -} - afterEach(() => { act(() => root?.unmount()); root = null; harness.appliedOptions = {}; harness.renderedItems = []; harness.setOptionsCalls = 0; - harness.openedDocuments = []; vi.unstubAllGlobals(); }); it.each(["left", "right", "bottom"] as const)( - "updates the %s header action when switching between documents with the same name", + "dispatches retained %s actions to the latest committed handlers without registering again", (placement) => { - renderDocument("first/guide.pdf", placement); - openRenderedDocument(); - renderDocument("second/guide.pdf", placement); - openRenderedDocument(); - - expect(harness.openedDocuments).toEqual(["first/guide.pdf", "second/guide.pdf"]); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(document.createElement("div")); + const onPress = vi.fn(); + const render = (path: string) => + act(() => + root!.render( + + onPress("button", path)} /> + + onPress("menu", path)}> + Open + + + onPress("nested", path)}> + Open + + + + , + ), + ); + + render("first/guide.pdf"); + const [button, menu] = harness.renderedItems; + if (button?.type !== "button" || menu?.type !== "menu") { + throw new Error("Expected the native button and menu"); + } + const [action, submenu] = menu.menu.items; + if (action?.type !== "action" || submenu?.type !== "submenu") { + throw new Error("Expected the direct action and submenu"); + } + const nested = submenu.items[0]; + if (nested?.type !== "action") throw new Error("Expected the nested action"); + const press = () => { + button.onPress?.(); + action.onPress?.(); + nested.onPress?.(); + }; + press(); + expect(onPress.mock.calls).toEqual([ + ["button", "first/guide.pdf"], + ["menu", "first/guide.pdf"], + ["nested", "first/guide.pdf"], + ]); + onPress.mockClear(); + const registrations = harness.setOptionsCalls; + expect(registrations).toBeGreaterThan(0); + + render("second/guide.pdf"); + + expect(harness.setOptionsCalls).toBe(registrations); + expect(harness.renderedItems).toEqual([button, menu]); + press(); + expect(onPress.mock.calls).toEqual([ + ["button", "second/guide.pdf"], + ["menu", "second/guide.pdf"], + ["nested", "second/guide.pdf"], + ]); }, ); @@ -150,3 +172,68 @@ it("applies a custom header element rendered inside a navigation container", () renderConnecting("Reconnecting"); expect(renderedCustomLabel()).toBe("Reconnecting"); }); + +it("keeps committed handlers while a replacement render suspends", () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(document.createElement("div")); + const onPress = vi.fn(); + const pending = new Promise(() => {}); + function Suspend(): never { + throw pending; + } + const toolbar = (value: string, suspend = false) => ( + + + onPress(value)} /> + + {suspend && } + + ); + act(() => root!.render(toolbar("committed"))); + const button = harness.renderedItems[0]; + if (button?.type !== "button") throw new Error("Expected the native button"); + + act(() => startTransition(() => root!.render(toolbar("pending", true)))); + button.onPress?.(); + expect(onPress.mock.calls).toEqual([["committed"]]); + + act(() => root!.render(toolbar("replacement"))); + button.onPress?.(); + expect(onPress.mock.calls).toEqual([["committed"], ["replacement"]]); +}); + +it("isolates toolbar handlers and ignores retained actions after their toolbar unmounts", () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(document.createElement("div")); + const onPress = vi.fn(); + const toolbar = (id: string, value: string) => ( + + onPress(id, value)} /> + + ); + act(() => root!.render([toolbar("first", "initial")])); + const first = harness.renderedItems[0]; + if (first?.type !== "button") throw new Error("Expected the first native button"); + act(() => root!.render([toolbar("first", "initial"), toolbar("second", "initial")])); + const second = harness.renderedItems[0]; + if (second?.type !== "button") throw new Error("Expected the second native button"); + + act(() => root!.render([toolbar("first", "updated"), toolbar("second", "updated")])); + first.onPress?.(); + second.onPress?.(); + expect(onPress.mock.calls).toEqual([ + ["first", "updated"], + ["second", "updated"], + ]); + + onPress.mockClear(); + act(() => root!.render([toolbar("second", "remaining")])); + first.onPress?.(); + second.onPress?.(); + expect(onPress.mock.calls).toEqual([["second", "remaining"]]); + + onPress.mockClear(); + act(() => root!.render(null)); + second.onPress?.(); + expect(onPress).not.toHaveBeenCalled(); +}); diff --git a/apps/mobile/src/native/NativeHeaderToolbar.tsx b/apps/mobile/src/native/NativeHeaderToolbar.tsx index 47a2ecb000862..88b72e2335fc1 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.tsx @@ -9,6 +9,7 @@ import { isValidElement, useLayoutEffect, useMemo, + useRef, use, type ReactElement, type ReactNode, @@ -60,6 +61,8 @@ function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { return undefined; } +type RegisterHandler = (handler: unknown) => () => void; + type ToolbarElementProps = Record & { readonly children?: ReactNode }; function elementTypeName(element: ReactElement): string | undefined { @@ -72,6 +75,7 @@ function elementTypeName(element: ReactElement): string | undefined { function convertMenuAction( element: ReactElement, + register: RegisterHandler, ): NativeStackHeaderItemMenu["menu"]["items"][number] | null { const typeName = elementTypeName(element); if (typeName === "NativeHeaderToolbarMenuAction") { @@ -82,10 +86,7 @@ function convertMenuAction( description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined, disabled: Boolean(element.props.disabled), icon: iconFromProp(element.props.icon), - onPress: - typeof element.props.onPress === "function" - ? (element.props.onPress as () => void) - : () => undefined, + onPress: register(element.props.onPress), state: element.props.isOn === true ? "on" : undefined, destructive: Boolean(element.props.destructive), discoverabilityLabel: @@ -104,30 +105,36 @@ function convertMenuAction( : labelFromChildren(element.props.children), icon: iconFromProp(element.props.icon), inline: Boolean(element.props.inline), - items: collectMenuItems(element.props.children), + items: collectMenuItems(element.props.children, register), }; } return null; } -function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] { +function collectMenuItems( + children: ReactNode, + register: RegisterHandler, +): NativeStackHeaderItemMenu["menu"]["items"] { const items: NativeStackHeaderItemMenu["menu"]["items"] = []; Children.forEach(children, (child) => { if (!isValidElement(child)) { return; } - const item = convertMenuAction(child); + const item = convertMenuAction(child, register); if (item) { items.push(item); return; } - items.push(...collectMenuItems(child.props.children)); + items.push(...collectMenuItems(child.props.children, register)); }); return items; } -function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { +function convertToolbarChild( + child: ReactNode, + register: RegisterHandler, +): NativeToolbarItem | null { if (!isValidElement(child)) { return null; } @@ -154,10 +161,7 @@ function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { axisBehavior: child.props.axisBehavior as NativeStackHeaderAxisBehavior, disabled: Boolean(child.props.disabled), icon: iconFromProp(child.props.icon), - onPress: - typeof child.props.onPress === "function" - ? (child.props.onPress as () => void) - : () => undefined, + onPress: register(child.props.onPress), sharesBackground: !child.props.separateBackground, tintColor: child.props.tintColor as ColorValue | undefined, variant: @@ -182,7 +186,7 @@ function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { icon: iconFromProp(child.props.icon), menu: { title: typeof child.props.title === "string" ? child.props.title : undefined, - items: collectMenuItems(child.props.children), + items: collectMenuItems(child.props.children, register), }, sharesBackground: !child.props.separateBackground, tintColor: child.props.tintColor as ColorValue | undefined, @@ -205,10 +209,10 @@ function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { return null; } -function collectToolbarItems(children: ReactNode): NativeToolbarItem[] { +function collectToolbarItems(children: ReactNode, register: RegisterHandler): NativeToolbarItem[] { const items: NativeToolbarItem[] = []; Children.forEach(children, (child) => { - const item = convertToolbarChild(child); + const item = convertToolbarChild(child, register); if (item) { if (item.type === "spacing") { // Native defaults missing spacing-item indices to zero, moving spacers before earlier siblings. @@ -228,7 +232,24 @@ function NativeHeaderToolbarRoot(props: { const route = useRoute(); const tabActivity = use(NativeWorkspaceTabActivityContext); const isTabActive = tabActivity?.isActive; - const items = useMemo(() => collectToolbarItems(props.children), [props.children]); + const committedHandlers = useRef<(() => void)[]>([]); + const { items, handlers } = useMemo(() => { + const handlers: (() => void)[] = []; + const register: RegisterHandler = (handler) => { + const index = handlers.length; + handlers.push(typeof handler === "function" ? (handler as () => void) : () => undefined); + return () => committedHandlers.current[index]?.(); + }; + return { items: collectToolbarItems(props.children, register), handlers }; + }, [props.children, committedHandlers]); + // Native retains actions when unchanged presentation skips re-registration. + // Publish only committed handlers; abandoned renders must not replace them. + useLayoutEffect(() => { + committedHandlers.current = handlers; + return () => { + committedHandlers.current = []; + }; + }, [handlers, committedHandlers]); // UIKit's search placement item belongs to the toolbar, never a header group. const headerItems = useMemo( () => items.filter((item): item is NativeStackHeaderItem => item.type !== "searchBarPlacement"), From 2da177c2a6ca305ce89a53a776ed91494ffafd51 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 01:03:41 -0700 Subject: [PATCH 012/222] fix(server): PR watch wakes say the PR stays linked and watched (#24781) Co-authored-by: Claude Opus 5.5 --- apps/server/src/orchestration-v2/pullRequestWatch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 810a899e17786..1213948cc4d6c 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -232,7 +232,7 @@ export function pullRequestWatchMessage(input: { "", exhausted ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.` - : "Look into each item and act on it as your task requires. T3 Code keeps watching and wakes you on the next change, so end your turn when you are done. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", + : "Look into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", ].join("\n"); const failed = changes.some( (change) => change.kind === "checks-failed" || change.kind === "conflicting", From 003b64dfa536e545079565d36ae0c2a5dba73b34 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 01:04:25 -0700 Subject: [PATCH 013/222] feat(server): thread tools return a ready t3-thread link (#24784) Co-authored-by: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 18 +++++++++--------- apps/server/src/mcp/OrchestratorMcpService.ts | 6 ++++++ .../src/mcp/toolkits/orchestrator/tools.ts | 4 ++-- .../src/mcp/toolkits/project/handlers.test.ts | 2 ++ .../src/mcp/toolkits/project/handlers.ts | 2 ++ apps/server/src/mcp/toolkits/project/tools.ts | 3 ++- .../server/src/mcp/toolkits/thread/handlers.ts | 9 ++++++++- apps/server/src/mcp/toolkits/thread/tools.ts | 1 + .../orchestrator-mcp-server.md | 3 ++- packages/contracts/src/orchestratorMcp.ts | 5 +++++ packages/shared/src/threadLinks.test.ts | 8 ++++++++ 11 files changed, 47 insertions(+), 14 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index cfb6408b79c03..e7dbfa7720dcb 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -89,7 +89,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [ { id: childRunId, @@ -223,7 +223,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [ { id: RunId.make("run:mcp-restart-child"), @@ -316,7 +316,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [], contextTransfers: [], messages: [], @@ -400,7 +400,7 @@ describe("OrchestratorMcpService", () => { }) as unknown as OrchestrationV2ThreadProjection; // A finished run whose binding to the follow-up was lost. const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: childState === "no runs" ? [] @@ -503,7 +503,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -583,7 +583,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -669,7 +669,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -764,7 +764,7 @@ describe("OrchestratorMcpService", () => { [ childThreadId, { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: RunId.make("run:mcp-cancel-grandchild-child"), status: "running" }], contextTransfers: [], messages: [], @@ -992,7 +992,7 @@ describe("OrchestratorMcpService provider resolution", () => { }) as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [], contextTransfers: [], messages: [], diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index f339561044007..d8704a88188be 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -67,6 +67,7 @@ import { } from "@t3tools/contracts"; import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, @@ -855,6 +856,7 @@ function listItemFromShell( return { threadId: shell.id, title: shell.title, + link: formatThreadLink(shell.id, shell.title), createdBy: shell.createdBy, creationSource: shell.creationSource, status: shell.activityRunStatus ?? shell.status, @@ -887,6 +889,7 @@ function threadDetail( threadId: projection.thread.id, projectId: projection.thread.projectId, title: projection.thread.title, + link: formatThreadLink(projection.thread.id, projection.thread.title), createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, status: active?.status ?? latest?.status ?? "idle", @@ -1597,6 +1600,7 @@ const make = Effect.gen(function* () { const response = { taskId: task.id, childThreadId: task.childThreadId, + link: formatThreadLink(task.childThreadId, childControls.thread.title), childRunId: childRun?.id ?? null, childNodeId: task.id, status, @@ -2648,6 +2652,7 @@ const make = Effect.gen(function* () { ); return { threadId, + link: formatThreadLink(threadId, projection.thread.title), runId: run?.id ?? null, status: run?.status ?? "idle", title: projection.thread.title, @@ -2839,6 +2844,7 @@ const make = Effect.gen(function* () { ); return { threadId: input.threadId, + link: formatThreadLink(input.threadId, target.thread.title), messageId, runId: result.run.id, ...(result.run.delegatedTaskId === undefined ? {} : { taskId: result.run.delegatedTaskId }), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 0aa0b430b7c79..c193a0afb1598 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -214,7 +214,7 @@ const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", + "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. Mention a thread with its `link`.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -228,7 +228,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", + "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. Mention a thread with its `link`.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 4c22393bf4761..ccc4443d0b7ce 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -81,6 +81,7 @@ const clientLaunchHarness = (input: { thread: { id: launch.threadId, projectId: launch.projectId, + title: launch.title, modelSelection: launch.modelSelection, }, runs: [], @@ -159,6 +160,7 @@ it.effect( expect(belowCeiling.result).toMatchObject({ projectId: belowCeiling.projectId, modelSelection: belowCeiling.modelSelection, + link: `[Fix](t3-thread://v1/${belowCeiling.launched[0]?.threadId})`, }); expect(belowCeiling.launched[0]?.runtimeMode).toBe("approval-required"); expect(belowCeiling.launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 9522c8d28fece..aaf37468f8cde 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -11,6 +11,7 @@ import * as Repositories from "../../../sourceControl/SourceControlRepositorySer import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; @@ -197,6 +198,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { const run = result.projection.runs.find((run) => run.userMessageId === messageId); return { threadId: thread.id, + link: formatThreadLink(thread.id, thread.title), projectId: thread.projectId, modelSelection: thread.modelSelection, runId: run?.id ?? null, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 2114565d8201e..fbe73215113cb 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -104,7 +104,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. Mention a thread with its `link`. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -133,6 +133,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { }), success: Schema.Struct({ threadId: ThreadId, + link: Schema.optional(Schema.String), projectId: ProjectId, modelSelection: ModelSelection, runId: Schema.NullOr(RunId), diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 1a841542eac9d..8a15d1f156561 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { modelSelectionCommandType } from "@t3tools/shared/model"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { @@ -105,9 +106,15 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { Effect.gen(function* () { const { caller } = yield* readCaller(); const threadSearch = yield* ThreadSearch.ThreadSearch; - return yield* threadSearch + const { matches } = yield* threadSearch .searchThreads({ ...input, projectId: input.projectId ?? caller?.projectId }) .pipe(Effect.mapError(unavailable)); + return { + matches: matches.map((match) => ({ + ...match, + link: formatThreadLink(match.threadId, match.title), + })), + }; }), ), t3_thread_fork: writesThread((input) => diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index c23f032522eb5..5e015368fd28c 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -254,6 +254,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", { threadId: OrchestrationThreadSearchMatch.fields.threadId, projectId: OrchestrationThreadSearchMatch.fields.projectId, title: Schema.String, + link: Schema.optional(Schema.String), source: Schema.Literals(["title", "user", "assistant"]), snippet: Schema.optional(OrchestrationThreadSearchMatch.fields.snippet), messageCreatedAt: Schema.optional(OrchestrationThreadSearchMatch.fields.messageCreatedAt), diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 4a53d19b00871..dc4af856b8c30 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -391,7 +391,8 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`. Actor and ingress are separate so agent-authored user-role messages remain distinguishable from human-authored messages. -Agents mention another thread as `[title](t3-thread://v1/)`. The +Agents mention another thread as `[title](t3-thread://v1/)`. Thread +results carry this ready-made as `link`, so agents never format the id. The link carries only the id. Clients first resolve it in the message's environment, then use a unique match in another enabled environment. Ambiguous matches stay unresolved. Web and desktop show the resolved thread’s current title. Mobile diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 55fe175237f4f..f6e80bce9e588 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -190,6 +190,7 @@ export type OrchestratorMcpDelegateTaskInput = typeof OrchestratorMcpDelegateTas export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ taskId: NodeId, childThreadId: ThreadId, + link: Schema.optional(Schema.String), childRunId: Schema.NullOr(RunId), childNodeId: NodeId, status: OrchestratorMcpDelegatedTaskStatus, @@ -273,6 +274,7 @@ export type OrchestratorMcpCreatedThreadStatus = typeof OrchestratorMcpCreatedTh export const OrchestratorMcpCreatedThread = Schema.Struct({ threadId: ThreadId, + link: Schema.optional(Schema.String), runId: Schema.NullOr(RunId), status: OrchestratorMcpCreatedThreadStatus, title: Schema.String, @@ -327,6 +329,7 @@ export type OrchestratorMcpThreadPullRequest = typeof OrchestratorMcpThreadPullR export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, title: Schema.String, + link: Schema.optional(Schema.String), createdBy: OrchestrationV2Actor, creationSource: OrchestrationV2CreationSource, status: OrchestratorMcpThreadStatus, @@ -394,6 +397,7 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, + link: Schema.optional(Schema.String), createdBy: OrchestrationV2Actor, creationSource: OrchestrationV2CreationSource, status: OrchestratorMcpThreadStatus, @@ -475,6 +479,7 @@ export type OrchestratorMcpThreadSendInput = typeof OrchestratorMcpThreadSendInp export const OrchestratorMcpThreadSendResult = Schema.Struct({ threadId: ThreadId, + link: Schema.optional(Schema.String), messageId: MessageId, runId: RunId, taskId: Schema.optional(NodeId), diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index ef27422dedf86..a6c8eebc7e65d 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -98,6 +98,14 @@ describe("thread links", () => { ); }); + it("writes the thread id verbatim so the link opens that exact thread", () => { + const link = formatThreadLink("thread:delegated-task:mcp%3A1", "Fix"); + expect(link).toBe("[Fix](t3-thread://v1/thread:delegated-task:mcp%3A1)"); + expect(parseThreadLinkHref(link.slice(link.indexOf("](") + 2, -1))).toBe( + "thread:delegated-task:mcp%3A1", + ); + }); + it("formats a label that would otherwise break the Markdown link", () => { expect(formatThreadLink("t1", "Fix [ci] \\ build")).toBe("[Fix ci build](t3-thread://v1/t1)"); expect(formatThreadLink("t1", " ] ")).toBe("[t1](t3-thread://v1/t1)"); From a96cf0818eff4a113c3ff52ae702683e51a5803b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 01:07:17 -0700 Subject: [PATCH 014/222] fix(server): running task results tell the agent to end its turn (#24782) Co-authored-by: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 109 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 33 +++++- packages/contracts/src/orchestratorMcp.ts | 7 ++ 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index e7dbfa7720dcb..6ce02cbb32e2a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -5,6 +5,7 @@ import { EnvironmentId, MessageId, NodeId, + ORCHESTRATOR_MCP_RUNNING_TASK_NEXT, type OrchestratorMcpCapabilitiesResult, OrchestratorMcpTarget, type OrchestrationV2ThreadShell, @@ -292,6 +293,114 @@ describe("OrchestratorMcpService", () => { }), ); + it.effect("tells the parent to end its turn only while its task still runs", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-next-parent"); + const childThreadId = ThreadId.make("thread:mcp-next-child"); + const taskId = NodeId.make("node:mcp-next-task"); + let finished = false; + let pendingRequest = false; + const parentProjection = () => + ({ + thread: { id: parentThreadId }, + runs: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + status: finished ? "completed" : "running", + result: finished ? "done" : null, + completionDelivery: { state: "pending" }, + }, + ], + }) as unknown as OrchestrationV2ThreadProjection; + const childRun = () => ({ + id: RunId.make("run:mcp-next-child"), + ordinal: 1, + status: finished ? ("completed" as const) : ("running" as const), + delegatedTaskId: taskId, + }); + const childProjection = () => + ({ + thread: { id: childThreadId, title: "Child task" }, + runs: [childRun()], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + runtimeRequests: pendingRequest ? [{ status: "pending" }] : [], + nodes: [], + turnItems: [], + }) as unknown as OrchestrationV2ThreadProjection; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection() : childProjection()), + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + getProjectThreadRecords: () => Effect.succeed(childProjection()), + waitForThread: () => + Effect.succeed({ + threadId: childThreadId, + run: childRun() as unknown as OrchestrationV2ThreadProjection["runs"][number], + timedOut: !finished, + }), + dispatch: () => Effect.succeed({} as never), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-next"), + requestNamespace: "provider-session:mcp-next", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-next", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const running = yield* service.taskStatus(scope, taskId); + assert.equal(running.status, "running"); + assert.equal(running.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + const waiting = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.isTrue(waiting.timedOut); + assert.equal(waiting.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + + // A child waiting on a question or approval needs an answer, not a wake. + pendingRequest = true; + const asking = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.isTrue(asking.timedOut); + assert.notProperty(asking, "next"); + pendingRequest = false; + + finished = true; + const completed = yield* service.taskStatus(scope, taskId); + assert.equal(completed.status, "completed"); + assert.notProperty(completed, "next"); + const waited = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.equal(waited.status, "completed"); + assert.notProperty(waited, "next"); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect("does not dispose delivery when a nonterminal task has no active child run", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-cancel-parent"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index d8704a88188be..0df296db5bc10 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -5,6 +5,7 @@ import { MessageId, type ModelSelection, NodeId, + ORCHESTRATOR_MCP_RUNNING_TASK_NEXT, TurnItemId, type OrchestrationV2Run, type OrchestrationV2Subagent, @@ -1597,7 +1598,7 @@ const make = Effect.gen(function* () { instanceId: task.providerInstanceId, model: task.model, }; - const response = { + const result = { taskId: task.id, childThreadId: task.childThreadId, link: formatThreadLink(task.childThreadId, childControls.thread.title), @@ -1633,6 +1634,10 @@ const make = Effect.gen(function* () { : pendingTaskApproval(childControls, task.id, childRun), waitTimedOut, } satisfies OrchestratorMcpDelegateTaskResult; + const response: OrchestratorMcpDelegateTaskResult = + endsTaskWait(result) || task.completionDelivery?.state === "disposed" + ? result + : { ...result, next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT }; if ( acknowledgeTerminal && isTerminalTaskStatus(status) && @@ -2853,7 +2858,7 @@ const make = Effect.gen(function* () { } satisfies OrchestratorMcpThreadSendResult; }), waitForThread: Effect.fn("OrchestratorMcpService.waitForThread")(function* (scope, input) { - const { target } = yield* loadScopedThread(scope, input.threadId); + const { parent, target } = yield* loadScopedThread(scope, input.threadId); const result = yield* threadManagement .waitForThread({ projectId: target.thread.projectId, @@ -2865,11 +2870,33 @@ const make = Effect.gen(function* () { ), }) .pipe(Effect.mapError(threadManagementFailure)); + const status = result.run === null ? "idle" : runOutcome(result.run); + const taskId = result.run?.delegatedTaskId; + const ownedTask = + result.timedOut && + status !== "waiting" && + parent?.subagents.some( + (task) => + task.id === taskId && + task.origin === "app_owned" && + task.threadId === parent.thread.id && + task.childThreadId === input.threadId && + task.completionDelivery?.state !== "disposed", + ) === true; + // A child waiting on a question or approval needs an answer, not a wake. + const childRequests = ownedTask + ? (yield* threadManagement + .getThreadRecords(input.threadId, ["runtimeRequests"]) + .pipe(Effect.mapError(threadManagementFailure))).runtimeRequests + : []; + const wakesCaller = + ownedTask && !childRequests.some((request) => request.status === "pending"); return { threadId: input.threadId, runId: result.run?.id ?? null, - status: result.run === null ? "idle" : runOutcome(result.run), + status, timedOut: result.timedOut, + ...(wakesCaller ? { next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT } : {}), } satisfies OrchestratorMcpThreadWaitResult; }), interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) { diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index f6e80bce9e588..49924b0686783 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -187,6 +187,11 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ }); export type OrchestratorMcpDelegateTaskInput = typeof OrchestratorMcpDelegateTaskInput.Type; +// Set only on results for still-running tasks whose completion will wake the parent. +export const ORCHESTRATOR_MCP_RUNNING_TASK_NEXT = + "Completion wakes this thread. End your turn unless you need this result now."; +const OrchestratorMcpRunningTaskNext = Schema.Literal(ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ taskId: NodeId, childThreadId: ThreadId, @@ -224,6 +229,7 @@ export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ description: "True only on that mode=wait call when timeoutMs elapsed. The timeout does not cancel the child. Later task_status reads return false and use status for liveness.", }), + next: Schema.optional(OrchestratorMcpRunningTaskNext), }); export type OrchestratorMcpDelegateTaskResult = typeof OrchestratorMcpDelegateTaskResult.Type; @@ -500,6 +506,7 @@ export const OrchestratorMcpThreadWaitResult = Schema.Struct({ runId: Schema.NullOr(RunId), status: OrchestratorMcpThreadStatus, timedOut: Schema.Boolean, + next: Schema.optional(OrchestratorMcpRunningTaskNext), }); export type OrchestratorMcpThreadWaitResult = typeof OrchestratorMcpThreadWaitResult.Type; From a8603afcf16623726374bdd44dcdc78a8049622e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 01:55:58 -0700 Subject: [PATCH 015/222] fix(server): mobile push no longer says "completed" between runs (#24769) Mobile push said "Thread completed" when a run ended and its continuation started a moment later, then again when the work really finished. Every completion now waits the relay's existing 5s confirm window and is dropped if the thread is live again. A thread that auto-settles during the hold still alerts its completion, then clears its card on a new window. Failed runs publish at once. Follow-up to #24760. Co-authored-by: Claude Opus 5.5 --- .../src/relay/AgentAwarenessRelay.test.ts | 111 ++++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 41 ++++++- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index b5f400dc3432f..3ed240d754cce 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -392,6 +392,8 @@ describe("AgentAwarenessRelay", () => { const terminal = shell({ status }); yield* Ref.set(currentShell, terminal); yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; assert.equal(publications[1]?.state?.phase, status); const now = yield* DateTime.now; @@ -419,6 +421,113 @@ describe("AgentAwarenessRelay", () => { }).pipe(Effect.scoped), ); + it.effect("holds a completion through a continuation gap, then publishes it once", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell()); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running"], + ); + + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running", "completed"], + ); + }), + ); + + it.effect("still publishes a completion when the thread settles during the hold", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed"], + ); + + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", null], + ); + }), + ); + + it.effect("drops a held completion when a queued continuation hides the thread", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "queued" })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.notInclude( + publications.map((publication) => publication.state?.phase ?? null), + "completed", + ); + }), + ); + + it.effect("retries a failed completion even after the thread settles", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay({ + respond: (attempt) => + attempt === 2 + ? new Response("unavailable", { status: 503 }) + : Response.json({ ok: true, deliveries: [] }), + }); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* TestClock.adjust("1 second"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", "completed"], + ); + + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state ?? null, null); + }), + ); + it.effect("keeps settled live work until its awaited monitor finishes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); @@ -595,6 +704,8 @@ describe("AgentAwarenessRelay", () => { yield* deliver( itemEvent("event:held-item:end", item("item:tool", { ...background, status: "completed" })), ); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; assert.deepEqual( publications.map((publication) => publication.state?.phase), ["running", "completed"], diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 93f3a73b21929..e59065400b2d9 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -411,6 +411,8 @@ export const make = Effect.gen(function* () { // Confirm through the same worker so tombstones cannot race live updates; recovery clears the deadline. const publishConfirmDeadlines = new Map(); + // Threads whose confirmation holds a completion, so a thread that settles during the hold still alerts. + const heldCompletions = new Set(); let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const publishRetries = new Map< ThreadId, @@ -429,6 +431,7 @@ export const make = Effect.gen(function* () { publishedRelayConfig = undefined; cachedRelayClient = undefined; publishConfirmDeadlines.clear(); + heldCompletions.clear(); yield* Ref.set(publishedStateByThreadRef, new Map()); }); @@ -542,6 +545,7 @@ export const make = Effect.gen(function* () { if (publishedStateByThread.get(threadId) === publishIdentity) { // Clear moot deadlines so later transient nulls cannot skip their deferral window. publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { environmentId, threadId, @@ -551,12 +555,14 @@ export const make = Effect.gen(function* () { } // Confirm tombstones over live state to avoid removing active cards. - // Confirm first-state completion because sessions briefly project completed before their first turn. + // Confirm completion: a run can end moments before its continuation starts, and the relay + // alerts on every move into completed. const requiresConfirmation = (snapshot.state === null && publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || - (snapshot.state?.phase === "completed" && !publishedStateByThread.has(threadId)); + snapshot.state?.phase === "completed"; if (requiresConfirmation) { + if (snapshot.state?.phase === "completed") heldCompletions.add(threadId); const nowMs = (yield* DateTime.now).epochMilliseconds; const deadline = publishConfirmDeadlines.get(threadId); if (deadline === undefined) { @@ -581,8 +587,38 @@ export const make = Effect.gen(function* () { statePhase: snapshot.state?.phase ?? null, shell: describeThreadShellForAwareness(thread), }); + // Settled during the hold, not resumed: alert the current completion, then clear the card + // on a new window. Only settlement may hide it; queued or silent runs also project to null. + const unsettled = + snapshot.state === null && + heldCompletions.has(threadId) && + Option.isSome(thread) && + thread.value.settledOverride === "settled" + ? resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread: Option.some({ ...thread.value, settledOverride: null }), + project, + }) + : null; + if (unsettled?.state?.phase === "completed") { + const completion = unsettled.state; + yield* publishState({ + projectId: unsettled.projectId, + state: completion, + reason: unsettled.reason, + }).pipe(withRelayClientTracing); + heldCompletions.delete(threadId); + publishConfirmDeadlines.set(threadId, nowMs + 5_000); + yield* Ref.update(publishedStateByThreadRef, (publishedStates) => + new Map(publishedStates).set(threadId, agentAwarenessPublishIdentity(completion)), + ); + yield* schedulePublishConfirm(threadId); + return; + } } else { publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); } if (snapshot.reason === "thread-not-found") { @@ -604,6 +640,7 @@ export const make = Effect.gen(function* () { reason: snapshot.reason, }).pipe(withRelayClientTracing); publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { const nextPublishedStates = new Map(publishedStates); if (snapshot.state === null) { From e641761e2aabd7488cc9049087d2ef6e8e24ddfc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 02:09:18 -0700 Subject: [PATCH 016/222] fix(server): delegated children no longer see pull request watch tools (#24808) Co-authored-by: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.test.ts | 87 +++++++++++++++++++ apps/server/src/mcp/McpInvocationContext.ts | 13 +++ apps/server/src/mcp/McpSessionRegistry.ts | 2 + .../src/mcp/toolkits/pullRequests/tools.ts | 9 +- .../ProviderSessionManager.test.ts | 53 +++++++++++ .../ProviderSessionManager.ts | 9 +- 6 files changed, 169 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 286dc409f743d..27e93f2ecefdb 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -817,3 +817,90 @@ it.effect("admits provider and OAuth client credentials and points only clients }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect("lists pull request watch tools only to threads that are not delegated children", () => + Effect.scoped( + Effect.gen(function* () { + const scopeFor = ( + token: string, + subagent: boolean, + ): McpInvocationContext.McpThreadInvocationScope => ({ + environmentId, + requestNamespace: token, + thread: { + threadId: ThreadId.make(`thread-${token}`), + providerSessionId: token, + providerInstanceId: ProviderInstanceId.make("codex"), + subagent, + }, + client: undefined, + capabilities: new Set(["pull-requests"]), + issuedAt: 1, + }); + const scopes = new Map([ + ["parentToken", scopeFor("parentToken", false)], + ["childToken", scopeFor("childToken", true)], + ]); + const serverLayer = McpHttpServer.layerPullRequestsToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: (token) => Effect.succeed(scopes.get(token)), + }), + ), + Layer.provide( + Layer.mergeAll( + McpToolAccessTestkit.liveThreadsLayer, + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}), + Layer.mock(Orchestrator.OrchestratorV2)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({}), + NodeServices.layer, + ), + ), + ); + yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const httpClient = yield* HttpClient.HttpClient; + const listedTools = (token: string) => + Effect.gen(function* () { + const headers = { + accept: "application/json, text/event-stream", + authorization: `Bearer ${token}`, + }; + const initialize = yield* httpClient.post("/mcp", { + headers, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, + "application/json", + ), + }); + const listed = yield* httpClient.post("/mcp", { + headers: { + ...headers, + "mcp-session-id": initialize.headers["mcp-session-id"]!, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, + "application/json", + ), + }); + const body = (yield* listed.json) as { + readonly result: { readonly tools: ReadonlyArray<{ readonly name: string }> }; + }; + return body.result.tools.map((tool) => tool.name); + }); + + const parentTools = yield* listedTools("parentToken"); + const childTools = yield* listedTools("childToken"); + expect(parentTools).toEqual( + expect.arrayContaining(["link_pull_request", "watch_pull_request", "unwatch_pull_request"]), + ); + expect(childTools).toContain("link_pull_request"); + expect(childTools).not.toContain("watch_pull_request"); + expect(childTools).not.toContain("unwatch_pull_request"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index c479b03df73d2..cc45af6bae4df 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; const ALL_MCP_CAPABILITIES = [ "preview", @@ -24,6 +25,8 @@ export interface McpThreadCaller { readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; + /** A delegated child task; its parent owns the work it hands back. */ + readonly subagent?: boolean; } export interface McpClientCaller { @@ -51,6 +54,16 @@ export class McpInvocationContext extends Context.Service< McpInvocationScope >()("t3/mcp/McpInvocationContext") {} +/** + * `McpSchema.EnabledWhen` for tools a delegated child must not see or call. + * EnabledWhen only receives the MCP client profile, so read the T3 caller from the request fiber. + */ +export const hiddenFromSubagents = (): boolean => { + const fiber = Fiber.getCurrent(); + const scope = fiber && Context.getOrUndefined(fiber.context, McpInvocationContext); + return scope?.thread?.subagent !== true; +}; + export type McpCapabilityError = C extends "preview" ? PreviewAutomationUnavailableError : McpCapabilityUnavailableError; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 2fc9879d7a343..ce424d7b662cd 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -18,6 +18,7 @@ export interface McpCredentialRequest { /** False omits the preview capability wherever the token is honored. Defaults to full access. */ readonly browserToolsAvailable?: boolean; readonly capabilities?: ReadonlySet; + readonly subagent?: boolean; } export interface McpIssuedCredential { @@ -105,6 +106,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + subagent: request.subagent === true, }, client: undefined, capabilities: new Set([ diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 63c0efa1684a4..cbc369deab053 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -10,6 +10,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import * as McpSchema from "effect/ai/McpSchema"; import * as Tool from "effect/ai/Tool"; import * as Toolkit from "effect/ai/Toolkit"; @@ -326,7 +327,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: - "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.", + "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch.", parameters: PullRequestTargetInput, success: WatchPullRequestResult, failure: PullRequestToolError, @@ -336,7 +337,8 @@ const WatchPullRequestTool = Tool.make("watch_pull_request", { .annotate(Tool.Readonly, false) .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true) - .annotate(Tool.OpenWorld, false); + .annotate(Tool.OpenWorld, false) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenFromSubagents); const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", { description: @@ -350,7 +352,8 @@ const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", { .annotate(Tool.Readonly, false) .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true) - .annotate(Tool.OpenWorld, false); + .annotate(Tool.OpenWorld, false) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenFromSubagents); const GuideTarget = Schema.Struct({ url: PullRequestTargetInput.fields.url, diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index a64997a8dd7b3..010f11742933e 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -1560,6 +1560,59 @@ it.effect( }), ); +it.effect.each([ + { relationshipToParent: null, subagent: false }, + { relationshipToParent: "subagent", subagent: true }, +] as const)( + "ProviderSessionManagerV2 marks MCP credentials of delegated children ($relationshipToParent)", + ({ relationshipToParent, subagent }) => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-mcp-lineage"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const created = yield* makeThreadCreatedEvent({ idAllocator, threadId, now }); + const parentThreadId = ThreadId.make("thread-provider-session-manager-mcp-parent"); + yield* eventSink.write({ + events: [ + { + ...created, + payload: { + ...created.payload, + lineage: + relationshipToParent === null + ? created.payload.lineage + : { parentThreadId, relationshipToParent, rootThreadId: parentThreadId }, + }, + }, + ], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + const token = (yield* Ref.get(mcpConfigs))[0]?.authorizationHeader.replace( + /^Bearer\s+/, + "", + ); + assert.isDefined(token); + assert.equal((yield* registry.resolve(token!))?.thread.subagent, subagent); + yield* manager.close(providerSessionId); + }); + + yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1_000, mcpConfigs }))); + }), +); + it.effect( "ProviderSessionManagerV2 withholds the preview capability when agent browser access is off", () => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index a9daa1995bc57..00b42d510bd91 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -430,6 +430,10 @@ export const layerWithOptions = ( >(["orchestration", "worktree", "pull-requests"]); if (browserToolsAvailable) capabilities.add("preview"); if (deviceToolsAvailable) capabilities.add("device"); + const subagent = yield* projectionStore.getThread(threadId).pipe( + Effect.map((thread) => thread.lineage.relationshipToParent === "subagent"), + Effect.orElseSucceed(() => false), + ); const existing = yield* mcpSessions.read(threadId); if (existing !== undefined) { // Reserve before async validation so release cannot revoke between validation and reservation. @@ -450,7 +454,9 @@ export const layerWithOptions = ( resolved.thread.threadId === threadId && resolved.thread.providerInstanceId === providerInstanceId && resolved.capabilities.has("preview") === browserToolsAvailable && - resolved.capabilities.has("device") === deviceToolsAvailable + resolved.capabilities.has("device") === deviceToolsAvailable && + // A credential issued while the lineage read failed is replaced once it works. + (resolved.thread.subagent === true) === subagent ) { return { mcpCredentialId: existing.providerSessionId, issued: false }; } @@ -462,6 +468,7 @@ export const layerWithOptions = ( providerInstanceId, browserToolsAvailable, capabilities, + subagent, }); yield* mcpSessions.set(credential.config); reserveMcpCredential(threadId, credential.config.providerSessionId); From 5398be0d44ef06fb1646d8ad54b81b5613a03193 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 02:11:12 -0700 Subject: [PATCH 017/222] fix(mobile): search stays open after going back from a thread (#24748) Co-authored-by: Claude Opus 5.5 --- .../features/workspace/WorkspaceToolbar.ios.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx index 821c4c70c34b5..0ac48ac5e233b 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx @@ -101,20 +101,14 @@ export function WorkspaceToolbar(props: { tab.searchMode !== "resting" ) { searchRef.current?.setText(tab.query); - if (duoSearch) { + // Integrated search collapses to its button when its screen reappears, + // which would hide the query that still filters the list. + if (NATIVE_LIQUID_GLASS_SUPPORTED) { searchRef.current?.setActive(true); if (tab.searchMode === "active") searchRef.current?.focus(); } } - }, [ - searchHostVisible, - duoSearch, - isTabActive, - selectedTab, - tab.searchMode, - tab.query, - searchVisible, - ]); + }, [searchHostVisible, isTabActive, selectedTab, tab.searchMode, tab.query, searchVisible]); useFocusEffect( useCallback(() => { const frame = requestAnimationFrame(restoreSearch); From e0b194021b151cd28e718f4cecd10095134dee45 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 02:28:07 -0700 Subject: [PATCH 018/222] feat(server): request_secret can save a CLI token to a private file (#24809) Co-authored-by: Claude Opus 5.5 --- .../features/threads/SecretRequestCard.tsx | 4 +- apps/server/src/mcp/OrchestratorMcpService.ts | 7 ++ .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/orchestration-v2/Orchestrator.ts | 1 + .../server/src/secrets/SecretRequests.test.ts | 119 ++++++++++++++++++ apps/server/src/secrets/SecretRequests.ts | 101 ++++++++++++++- .../src/components/chat/SecretRequestCard.tsx | 4 +- .../client-runtime/src/secretRequest.test.ts | 13 +- packages/client-runtime/src/secretRequest.ts | 6 +- packages/contracts/src/orchestrationV2.ts | 5 + packages/contracts/src/orchestratorMcp.ts | 11 ++ packages/contracts/src/secretRequest.ts | 2 + .../src/server/orchestrationInstructions.ts | 2 +- 13 files changed, 264 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/SecretRequestCard.tsx b/apps/mobile/src/features/threads/SecretRequestCard.tsx index 02d773c13f128..0112257fec713 100644 --- a/apps/mobile/src/features/threads/SecretRequestCard.tsx +++ b/apps/mobile/src/features/threads/SecretRequestCard.tsx @@ -1,9 +1,9 @@ import { SECRET_REQUEST_DEFAULT_PLACEHOLDER, - SECRET_REQUEST_PRIVACY_NOTE, secretRequestAnswerInput, secretRequestDisplay, secretRequestFailureMessage, + secretRequestPrivacyNote, type SecretRequestItem, } from "@t3tools/client-runtime/secret-request"; import { @@ -144,7 +144,7 @@ function PendingSecretRequestForm(props: { type="monochrome" /> - {SECRET_REQUEST_PRIVACY_NOTE} + {secretRequestPrivacyNote(item)}