From 1e15bd0e47ee0139d840e856532ff7cf66f3d8ce Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 00:42:15 +0000 Subject: [PATCH 001/241] perf(server): start child processes from a spawn host on linux On Linux libuv forks to spawn, and the kernel copies the server's page tables while the event loop waits: 10-30 ms per git, gh, rg or provider CLI once the server holds a large heap. A small host process now starts the server's children and hands their stdio sockets back over IPC, so streams, exit codes, signals, process groups and kills behave as before. The Effect spawner takes the spawn function as a parameter (patch), and the HTTP server layer no longer re-exports the stock platform services, which had replaced the server's spawner for most layers. macOS and Windows spawn directly; a host that cannot start or keeps dying falls back to direct spawns. --- apps/server/src/bin.ts | 6 +- apps/server/src/process/SpawnHost.ts | 385 ++++++++++++++++++ apps/server/src/process/spawnHostProcess.ts | 106 +++++ apps/server/src/processRunner.test.ts | 113 +++++ apps/server/src/server.ts | 12 +- .../@effect__platform-node-shared@4.0.2.patch | 73 +++- pnpm-lock.yaml | 8 +- pnpm-workspace.yaml | 4 +- 8 files changed, 697 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/process/SpawnHost.ts create mode 100644 apps/server/src/process/spawnHostProcess.ts diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 063093874503d..bb4a429800e88 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -5,7 +5,8 @@ * terminal-fallback agents run `t3 acp-mcp-call` per tool call, so their * startup sits on first-message latency. Both dispatch here before the full * CLI module graph (seconds of evaluation) loads; everything else defers to - * the real CLI in ./binCli.ts. + * the real CLI in ./binCli.ts. The server's spawn host (`t3 __spawn-host`) + * starts here for the same reason, and to stay small. */ import { isEntrypoint } from "./entrypoint.ts"; @@ -20,6 +21,9 @@ if ( if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts"); await runAcpMcpCliFastPath(command, process.argv.slice(3)); + } else if (command === "__spawn-host") { + const { runSpawnHost } = await import("./process/spawnHostProcess.ts"); + runSpawnHost(); } else { const { runCli } = await import("./binCli.ts"); runCli(); diff --git a/apps/server/src/process/SpawnHost.ts b/apps/server/src/process/SpawnHost.ts new file mode 100644 index 0000000000000..d3a2ea5e06395 --- /dev/null +++ b/apps/server/src/process/SpawnHost.ts @@ -0,0 +1,385 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off - the host client drives a Node child process from its IPC callbacks, outside the Effect runtime. +/** + * The server's `ChildProcessSpawner`. On Linux, libuv forks to spawn, and the + * kernel copies the server's page tables while the event loop waits: 10-60 ms + * per git, gh, rg or provider CLI once the heap is large. Children start from + * the small spawn host process instead (spawnHostProcess.ts), and their stdio + * sockets come back to this process, so streams, exit codes, signals, process + * groups and kills behave as with a direct spawn. macOS spawns with + * posix_spawn and Windows with CreateProcess, which copy nothing, so they + * spawn directly. If the host cannot start or keeps dying, children spawn + * directly too. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import type * as NodeNet from "node:net"; +import type * as NodeStream from "node:stream"; + +import * as NodeChildProcessSpawner from "@effect/platform-node-shared/NodeChildProcessSpawner"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; + +import type { SpawnHostError, SpawnHostEvent, SpawnHostSpawnRequest } from "./spawnHostProcess.ts"; + +/** After this many host crashes or failed starts, children spawn directly. */ +const MAX_HOST_FAILURES = 3; +const HOST_READY_TIMEOUT_MS = 10_000; +/** The options the Effect spawner passes; anything else spawns directly. */ +const HOSTED_OPTION_KEYS: ReadonlySet = new Set([ + "cwd", + "env", + "stdio", + "detached", + "shell", + "windowsHide", +]); +const HOSTED_STDIO: ReadonlySet = new Set([undefined, null, "pipe", "ignore", "inherit"]); + +const canHost = (options: NodeChildProcess.SpawnOptions) => + Object.keys(options).every((key) => HOSTED_OPTION_KEYS.has(key)) && + Array.isArray(options.stdio) && + options.stdio.every((stdio) => HOSTED_STDIO.has(stdio)); + +const toSpawnError = (error: SpawnHostError) => + Object.assign(new Error(error.message), { + code: error.code, + errno: error.errno, + syscall: error.syscall, + path: error.path, + spawnargs: error.spawnargs, + }); + +const killGroupOrProcess = (pid: number, signal: NodeJS.Signals) => { + try { + process.kill(-pid, signal); + } catch { + try { + process.kill(pid, signal); + } catch { + // Already gone. + } + } +}; + +export interface SpawnHostFailure { + readonly failures: number; + /** Every later child spawns directly. */ + readonly disabled: boolean; + readonly cause: unknown; +} + +/** A child started by the host, shaped like the `ChildProcess` the Effect spawner reads. */ +class HostedChildProcess + extends NodeEvents.EventEmitter + implements NodeChildProcessSpawner.SpawnedChildProcess +{ + pid: number | undefined = undefined; + stdin: NodeStream.Writable | null = null; + stdout: NodeStream.Readable | null = null; + stderr: NodeStream.Readable | null = null; + stdio: Array = []; + state: "queued" | "sent" | "running" | "exited" = "queued"; + referenced = true; + readonly request: SpawnHostSpawnRequest; + readonly #client: SpawnHostClient; + #pendingSignal: NodeJS.Signals | number | undefined; + #direct: NodeChildProcess.ChildProcess | undefined; + + constructor(client: SpawnHostClient, request: SpawnHostSpawnRequest) { + super(); + this.#client = client; + this.request = request; + } + + kill(signal: NodeJS.Signals | number = "SIGTERM"): boolean { + if (this.#direct) return this.#direct.kill(signal); + if (this.state === "exited") return false; + if (this.state === "queued") { + // Never sent: dropping the request is the whole kill. + this.#client.settle(this); + return true; + } + if (this.pid === undefined) { + this.#pendingSignal = signal; + return true; + } + try { + process.kill(this.pid, signal); + return true; + } catch { + return false; + } + } + + ref(): void { + if (this.#direct) this.#direct.ref(); + else this.#client.setReferenced(this, true); + } + + unref(): void { + if (this.#direct) this.#direct.unref(); + else this.#client.setReferenced(this, false); + } + + attachStdio(fd: number, socket: NodeNet.Socket): void { + this.stdio[fd] = socket; + if (fd === 0) this.stdin = socket; + else if (fd === 1) this.stdout = socket; + else if (fd === 2) this.stderr = socket; + } + + markSpawned(pid: number): void { + this.state = "running"; + this.pid = pid; + if (this.#pendingSignal !== undefined) this.kill(this.#pendingSignal); + this.emit("spawn"); + } + + markExited(code: number | null, signal: NodeJS.Signals | null): void { + this.#client.settle(this); + // Node destroys a child's stdin when it exits. + this.stdin?.destroy(); + this.emit("exit", code, signal); + } + + markFailed(error: Error): void { + this.#client.settle(this); + this.emit("error", error); + } + + /** Spawns from this process a request no host ever received. */ + spawnDirectly(): void { + this.#client.settle(this); + const { command, args, options } = this.request; + const child = NodeChildProcess.spawn(command, [...args], options); + this.#direct = child; + this.stdin = child.stdin; + this.stdout = child.stdout; + this.stderr = child.stderr; + this.stdio = [...child.stdio]; + if (!this.referenced) child.unref(); + child.on("error", (error) => this.emit("error", error)); + child.on("spawn", () => { + this.pid = child.pid; + this.emit("spawn"); + }); + child.on("exit", (code, signal) => this.emit("exit", code, signal)); + } +} + +class SpawnHostClient { + readonly #command: string; + readonly #args: ReadonlyArray; + readonly #onHostFailure: (failure: SpawnHostFailure) => void; + readonly #children = new Map(); + #host: NodeChildProcess.ChildProcess | undefined; + #ready = false; + #closed = false; + #failures = 0; + #nextId = 0; + #referencedChildren = 0; + #readyTimer: NodeJS.Timeout | undefined; + + constructor( + command: string, + args: ReadonlyArray, + onHostFailure: (failure: SpawnHostFailure) => void, + ) { + this.#command = command; + this.#args = args; + this.#onHostFailure = onHostFailure; + } + + readonly spawn = ( + command: string, + args: ReadonlyArray, + options: NodeChildProcess.SpawnOptions, + ): NodeChildProcessSpawner.SpawnedChildProcess => { + if (this.#closed || this.#failures >= MAX_HOST_FAILURES || !canHost(options)) { + return NodeChildProcess.spawn(command, [...args], options); + } + const child = new HostedChildProcess(this, { + type: "spawn", + id: ++this.#nextId, + command, + args, + // The host's own cwd and env are a snapshot from its start; send this process's. + options: { ...options, cwd: options.cwd ?? process.cwd(), env: options.env ?? process.env }, + }); + this.#children.set(child.request.id, child); + this.#referencedChildren++; + if (this.#ready) this.#send(child); + else this.#start(); + this.#syncChannelRef(); + return child; + }; + + /** Forgets a child that exited, failed, was dropped, or moved to a direct spawn. */ + settle(child: HostedChildProcess): void { + if (child.state === "exited") return; + child.state = "exited"; + this.#children.delete(child.request.id); + if (child.referenced) this.#referencedChildren--; + this.#syncChannelRef(); + } + + setReferenced(child: HostedChildProcess, referenced: boolean): void { + if (child.referenced === referenced) return; + child.referenced = referenced; + if (child.state !== "exited") this.#referencedChildren += referenced ? 1 : -1; + this.#syncChannelRef(); + } + + close(): void { + this.#closed = true; + clearTimeout(this.#readyTimer); + const host = this.#host; + this.#host = undefined; + if (host?.connected) host.disconnect(); + } + + // Like a direct child's process handle, the channel keeps this process + // alive only while a referenced child runs. + #syncChannelRef(): void { + const channel = this.#host?.channel; + if (!channel) return; + if (this.#referencedChildren > 0) channel.ref(); + else channel.unref(); + } + + #send(child: HostedChildProcess): void { + child.state = "sent"; + this.#host?.send(child.request); + } + + #start(): void { + if (this.#host) return; + const env = { ...process.env }; + // The host runs only its own module; preloads and flags meant for the server stay out. + delete env.NODE_OPTIONS; + let host: NodeChildProcess.ChildProcess; + try { + host = NodeChildProcess.spawn(this.#command, [...this.#args], { + env, + stdio: ["inherit", "inherit", "inherit", "ipc"], + }); + } catch (cause) { + this.#failures++; + this.#onHostLost(cause); + return; + } + this.#host = host; + host.unref(); + this.#readyTimer = setTimeout(() => { + host.kill("SIGKILL"); + this.#lose(host, new Error("The spawn host did not start in time.")); + }, HOST_READY_TIMEOUT_MS); + this.#readyTimer.unref(); + host.on("message", (event, socket) => { + if (host === this.#host) { + this.#onEvent(event as SpawnHostEvent, socket as NodeNet.Socket | undefined); + } + }); + host.once("error", (cause) => this.#lose(host, cause)); + // `close` follows the last IPC message, so every exit the host reported is handled first. + host.once("close", (code, signal) => + this.#lose(host, new Error(`The spawn host exited (code ${code}, signal ${signal}).`)), + ); + } + + #onEvent(event: SpawnHostEvent, socket: NodeNet.Socket | undefined): void { + if (event.type === "ready") { + clearTimeout(this.#readyTimer); + this.#ready = true; + for (const child of this.#children.values()) { + if (child.state === "queued") this.#send(child); + } + return; + } + const child = this.#children.get(event.id); + if (!child) { + socket?.destroy(); + return; + } + switch (event.type) { + case "stdio": + if (socket) child.attachStdio(event.fd, socket); + return; + case "spawn": + child.markSpawned(event.pid); + return; + case "exit": + child.markExited(event.code, event.signal); + return; + case "error": + child.markFailed(toSpawnError(event.error)); + return; + } + } + + #lose(host: NodeChildProcess.ChildProcess, cause: unknown): void { + if (host !== this.#host) return; + clearTimeout(this.#readyTimer); + this.#host = undefined; + this.#ready = false; + if (host.connected) host.disconnect(); + if (this.#closed) return; + this.#failures++; + this.#onHostLost(cause); + } + + #onHostLost(cause: unknown): void { + const disabled = this.#failures >= MAX_HOST_FAILURES; + this.#onHostFailure({ failures: this.#failures, disabled, cause }); + for (const child of this.#children.values()) { + if (child.state === "queued") { + // The host never saw it, so it cannot have started. It waits for the next host. + if (disabled) child.spawnDirectly(); + } else if (child.state === "sent") { + // It may have started; spawning again could run it twice. + child.markFailed(new Error("The spawn host exited before reporting the spawn.")); + } else if (child.pid !== undefined) { + // Its exit status left with the host. Stop the group it leads, as a scope release would. + killGroupOrProcess(child.pid, "SIGKILL"); + child.markExited(null, "SIGKILL"); + } + } + if (this.#children.size > 0 && !disabled) this.#start(); + } +} + +/** Starts children from the spawn host that `command args` runs. */ +export const layerHost = ( + command: string, + args: ReadonlyArray, + onHostFailure: (failure: SpawnHostFailure) => void, +) => + Layer.unwrap( + Effect.acquireRelease( + Effect.sync(() => new SpawnHostClient(command, args, onHostFailure)), + (client) => Effect.sync(() => client.close()), + ).pipe(Effect.map((client) => NodeChildProcessSpawner.layerWithSpawn(client.spawn))), + ); + +export const layer = Layer.unwrap( + Effect.gen(function* () { + if ((yield* HostProcessPlatform) !== "linux") return NodeChildProcessSpawner.layer; + const invocation = yield* resolveSelfInvocation(); + const runFork = Effect.runForkWith(yield* Effect.context()); + return layerHost( + invocation.command, + selfInvocationArgs(invocation, ["__spawn-host"]), + ({ failures, disabled, cause }) => + runFork( + Effect.logWarning( + disabled + ? "The spawn host failed; child processes now start from the server." + : "The spawn host stopped; it restarts for the next child process.", + { failures, cause }, + ), + ), + ); + }), +); diff --git a/apps/server/src/process/spawnHostProcess.ts b/apps/server/src/process/spawnHostProcess.ts new file mode 100644 index 0000000000000..7e54c7ac3b74f --- /dev/null +++ b/apps/server/src/process/spawnHostProcess.ts @@ -0,0 +1,106 @@ +// @effect-diagnostics nodeBuiltinImport:off - the host runs without the Effect runtime to stay small. +// The spawn host: a small Node process that starts the server's children. +// Linux libuv forks to spawn, which copies the parent's page tables and +// blocks its event loop for the copy; a large server pays 10-60 ms per child. +// This process stays small, so its forks are cheap and off the server's +// thread. Each child's stdio sockets go back to the server over the IPC +// channel, so output never passes through here. +// +// `t3 __spawn-host` runs this from bin.ts before the CLI module graph loads, +// so it may import only Node builtins. Nothing runs on import. +import * as NodeChildProcess from "node:child_process"; +import type * as NodeNet from "node:net"; + +export interface SpawnHostSpawnRequest { + readonly type: "spawn"; + readonly id: number; + readonly command: string; + readonly args: ReadonlyArray; + readonly options: NodeChildProcess.SpawnOptions; +} + +export interface SpawnHostError { + readonly message: string; + readonly code?: string | undefined; + readonly errno?: number | undefined; + readonly syscall?: string | undefined; + readonly path?: string | undefined; + readonly spawnargs?: ReadonlyArray | undefined; +} + +/** Sent in order per child: `stdio` once per piped fd (carrying the socket), `spawn`, then `exit`. */ +export type SpawnHostEvent = + | { readonly type: "ready" } + | { readonly type: "stdio"; readonly id: number; readonly fd: number } + | { readonly type: "spawn"; readonly id: number; readonly pid: number } + | { + readonly type: "exit"; + readonly id: number; + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + } + | { readonly type: "error"; readonly id: number; readonly error: SpawnHostError }; + +const toSpawnHostError = (cause: unknown): SpawnHostError => { + const error = cause as Partial & { spawnargs?: ReadonlyArray }; + return { + message: typeof error?.message === "string" ? error.message : String(cause), + code: error?.code, + errno: error?.errno, + syscall: error?.syscall, + path: error?.path, + spawnargs: error?.spawnargs, + }; +}; + +/** + * Stops the read Node starts on each stdio socket as it creates it. Child + * stdio has no public paused option; net's `pauseOnCreate` makes this same call. + */ +const stopReading = (socket: NodeNet.Socket) => + (socket as unknown as { _handle?: { readStop?: () => void } | null })._handle?.readStop?.(); + +export function runSpawnHost(): void { + const channel = process.send?.bind(process); + if (!channel) { + process.stderr.write("t3 __spawn-host is started by the T3 server.\n"); + process.exitCode = 1; + return; + } + const send = (event: SpawnHostEvent, socket?: NodeNet.Socket) => channel(event, socket); + + // The server decides when its children stop. A terminal's Ctrl-C or a service + // manager's SIGTERM reaches the whole group, and the host must keep reporting + // exits while the server cleans up; it leaves when the server's channel closes. + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, () => {}); + process.on("disconnect", () => process.exit(0)); + + process.on("message", (request: SpawnHostSpawnRequest) => { + if (request.type !== "spawn") return; + const { id } = request; + let child: NodeChildProcess.ChildProcess; + try { + child = NodeChildProcess.spawn(request.command, [...request.args], request.options); + } catch (cause) { + send({ type: "error", id, error: toSpawnHostError(cause) }); + return; + } + // Every byte the child writes must stay in the kernel until the server owns + // the socket. The loop has not polled yet, so nothing was read so far. + const stdio = child.stdio.map((socket) => { + if (socket) stopReading(socket as NodeNet.Socket); + return socket as NodeNet.Socket | null; + }); + // ChildProcess destroys stdin on exit, which can come before its socket leaves the send queue. + child.stdin = null; + child.on("error", (cause) => send({ type: "error", id, error: toSpawnHostError(cause) })); + child.once("spawn", () => { + stdio.forEach((socket, fd) => { + if (socket) send({ type: "stdio", id, fd }, socket); + }); + send({ type: "spawn", id, pid: child.pid! }); + }); + child.on("exit", (code, signal) => send({ type: "exit", id, code, signal })); + }); + send({ type: "ready" }); +} diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 3bec75351baf7..8bb6e07f2d136 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,3 +1,10 @@ +// @effect-diagnostics nodeBuiltinImport:off - the spawn host tests watch real child processes. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -12,6 +19,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import * as SpawnHost from "./process/SpawnHost.ts"; import * as ProcessRunner from "./processRunner.ts"; type ChildProcessCommand = { @@ -401,6 +409,111 @@ describe("runProcess", () => { ); }); +const hostPlatform = HostProcessPlatform.defaultValue(); + +describe.skipIf(hostPlatform === "win32")("runProcess through the spawn host", () => { + const hostFailures: Array = []; + const layer = ProcessRunner.layer.pipe( + Layer.provideMerge( + SpawnHost.layerHost( + process.execPath, + [NodeURL.fileURLToPath(new URL("./bin.ts", import.meta.url)), "__spawn-host"], + (failure) => hostFailures.push(failure), + ), + ), + Layer.provide(NodeServices.layer), + ); + const run = (input: ProcessRunner.ProcessRunInput) => + Effect.flatMap(Effect.service(ProcessRunner.ProcessRunner), (runner) => runner.run(input)); + const isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + /** Runs `sleep 30` that records its pid, so the test can find it. */ + const sleeper = (pidFile: string) => ({ + command: "sh", + args: ["-c", `echo $$ > '${pidFile}'; exec sleep 30`], + }); + let pidFiles = 0; + const tempPidFile = () => + NodePath.join(NodeOS.tmpdir(), `t3-spawn-host-${process.pid}-${++pidFiles}.pid`); + + it.live("streams stdin, stdout, stderr and the exit code", () => + Effect.gen(function* () { + const input = `${"x".repeat(2_000_000)}\n`; + const echoed = yield* run({ + command: "sh", + args: ["-c", "cat; echo done >&2; exit 3"], + stdin: input, + maxOutputBytes: 4_000_000, + }); + expect(echoed.stdout).toBe(input); + expect(echoed.stderr).toBe("done\n"); + expect(echoed.code).toBe(3); + + const envAndCwd = yield* run({ + command: "sh", + args: ["-c", 'printf "%s %s" "$SPAWN_HOST_VALUE" "$PWD"'], + cwd: "/", + env: { SPAWN_HOST_VALUE: "from-server" }, + }); + expect(envAndCwd.stdout).toBe("from-server /"); + }).pipe(Effect.provide(layer)), + ); + + it.live("reports a missing executable as a spawn error", () => + Effect.gen(function* () { + const error = yield* run({ command: "t3-spawn-host-missing-command", args: [] }).pipe( + Effect.flip, + ); + if (error._tag !== "ProcessSpawnError") return expect.fail(`Got ${error._tag}`); + expect(String(error.cause)).toContain("NotFound"); + }).pipe(Effect.provide(layer)), + ); + + it.live("kills a child that times out", () => + Effect.gen(function* () { + const pidFile = tempPidFile(); + const error = yield* run({ ...sleeper(pidFile), timeout: "300 millis" }).pipe(Effect.flip); + const pid = Number(NodeFS.readFileSync(pidFile, "utf8")); + NodeFS.rmSync(pidFile); + expect(error._tag).toBe("ProcessTimeoutError"); + expect(isAlive(pid)).toBe(false); + }).pipe(Effect.provide(layer)), + ); + + it.live.skipIf(hostPlatform !== "linux")( + "fails and stops a running child when the host dies, then starts a new host", + () => + Effect.gen(function* () { + const pidFile = tempPidFile(); + const running = yield* run(sleeper(pidFile)).pipe(Effect.flip, Effect.forkScoped); + while (!NodeFS.existsSync(pidFile)) yield* Effect.sleep("20 millis"); + yield* Effect.sleep("50 millis"); + const childPid = Number(NodeFS.readFileSync(pidFile, "utf8")); + NodeFS.rmSync(pidFile); + // The child's parent is the host: `pid (comm) state ppid ...`. + const hostPid = Number( + NodeFS.readFileSync(`/proc/${childPid}/stat`, "utf8").split(") ")[1]?.split(" ")[1], + ); + expect(hostPid).not.toBe(process.pid); + process.kill(hostPid, "SIGKILL"); + + const error = yield* Fiber.join(running); + expect(error._tag).toBe("ProcessReadError"); + expect(isAlive(childPid)).toBe(false); + expect(hostFailures.at(-1)).toMatchObject({ failures: 1, disabled: false }); + + const after = yield* run({ command: "echo", args: ["again"] }); + expect(after.stdout).toBe("again\n"); + }).pipe(Effect.provide(layer)), + ); +}); + describe("isWindowsCommandNotFound", () => { it.effect("matches the localized German cmd.exe error text", () => Effect.gen(function* () { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 82b3c31d75b44..cd8847071100e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -9,6 +9,7 @@ import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchRea // @effect-diagnostics nodeBuiltinImport:off import * as NodeHttp from "node:http"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentHttpApi, type RepositoryIdentity } from "@t3tools/contracts"; @@ -20,7 +21,7 @@ import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import * as Schedule from "effect/Schedule"; -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/http"; +import { Etag, FetchHttpClient, HttpRouter, HttpServer } from "effect/http"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; @@ -36,6 +37,7 @@ import { fixPath } from "./os-jank.ts"; import * as Ws from "./ws.ts"; import * as AgentScopeLive from "./process/agentScope.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; +import * as SpawnHost from "./process/SpawnHost.ts"; import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; import * as PullRequestHttp from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; @@ -288,7 +290,9 @@ const layerHttpServer = Layer.unwrap( const config = yield* ServerConfig.ServerConfig; const createServer = () => holdRequestsUntilServing(guardHttpResponseWriteErrors(NodeHttp.createServer())); - return NodeHttpServer.layer(createServer, { + // `NodeHttpServer.layer` would also re-export the stock Node platform services, + // replacing the spawn host's `ChildProcessSpawner` for everything built on it. + const layerServer = NodeHttpServer.layerServer(createServer, { host: config.host ?? "127.0.0.1", port: config.port, gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, @@ -300,10 +304,12 @@ const layerHttpServer = Layer.unwrap( // `threshold` when context takeover is disabled). websocket: { perMessageDeflate: true }, }); + return Layer.mergeAll(layerServer, NodeHttpPlatform.layer, Etag.layerWeak); }), ); -const layerPlatformServices = NodeServices.layer; +// Children start from the spawn host, so forking never blocks this event loop. +const layerPlatformServices = SpawnHost.layer.pipe(Layer.provideMerge(NodeServices.layer)); const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.layerConfig)); diff --git a/patches/@effect__platform-node-shared@4.0.2.patch b/patches/@effect__platform-node-shared@4.0.2.patch index 98dff40c04bba..582e6cc185b02 100644 --- a/patches/@effect__platform-node-shared@4.0.2.patch +++ b/patches/@effect__platform-node-shared@4.0.2.patch @@ -1,7 +1,55 @@ +diff --git a/dist/NodeChildProcessSpawner.d.ts b/dist/NodeChildProcessSpawner.d.ts +index 6cfa36e..ddef269 100644 +--- a/dist/NodeChildProcessSpawner.d.ts ++++ b/dist/NodeChildProcessSpawner.d.ts +@@ -33,6 +33,8 @@ import * as Layer from "effect/Layer"; + import * as Path from "effect/Path"; + import * as ChildProcess from "effect/process/ChildProcess"; + import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; ++import type * as NodeChildProcess from "node:child_process"; ++import type { Readable, Writable } from "node:stream"; + /** + * Layer that provides the `NodeChildProcessSpawner` implementation. + * +@@ -41,6 +43,25 @@ import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; + * @since 4.0.0 + */ + export declare const layer: Layer.Layer; ++/** ++ * The part of `ChildProcess` the spawner uses: `spawn`, `error` and `exit` ++ * events, the pid, stdio streams, `kill`, `ref` and `unref`. ++ */ ++export interface SpawnedChildProcess extends NodeJS.EventEmitter { ++ readonly pid?: number | undefined; ++ readonly stdin: Writable | null; ++ readonly stdout: Readable | null; ++ readonly stderr: Readable | null; ++ readonly stdio: ReadonlyArray; ++ kill(signal?: NodeJS.Signals | number): boolean; ++ ref(): void; ++ unref(): void; ++} ++/** ++ * `layer` with a replacement for `child_process.spawn` that returns a ++ * `ChildProcess`-compatible object. ++ */ ++export declare const layerWithSpawn: (spawnProcess: (command: string, args: ReadonlyArray, options: NodeChildProcess.SpawnOptions) => SpawnedChildProcess) => Layer.Layer; + /** + * Result of flattening a pipeline of commands. + * diff --git a/dist/NodeChildProcessSpawner.js b/dist/NodeChildProcessSpawner.js -index b3e4dea..d6379fa 100644 +index b3e4dea..1b82509 100644 --- a/dist/NodeChildProcessSpawner.js +++ b/dist/NodeChildProcessSpawner.js +@@ -47,7 +47,7 @@ const isProcessAlive = (childProcess, exitSignal) => { + const taskkill = (childProcess, onExit = () => {}) => NodeChildProcess.execFile("taskkill", ["/pid", String(childProcess.pid), "/T", "/F"], { + windowsHide: true + }, onExit); +-const make = /*#__PURE__*/Effect.gen(function* () { ++const makeWithSpawn = spawnProcess => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolveWorkingDirectory = Effect.fnUntraced(function* (options) { @@ -170,6 +170,10 @@ const make = /*#__PURE__*/Effect.gen(function* () { // Create a sink to write to for input file descriptors let sink = Sink.drain; @@ -13,3 +61,26 @@ index b3e4dea..d6379fa 100644 sink = NodeSink.fromWritable({ evaluate: () => nodeStream, onError: error => toPlatformError(`fromWritable(fd${fd})`, toError(error), command) +@@ -261,7 +265,7 @@ const make = /*#__PURE__*/Effect.gen(function* () { + }; + const spawn = (command, spawnOptions) => Effect.callback(resume => { + const deferred = Deferred.makeUnsafe(); +- const handle = NodeChildProcess.spawn(command.command, command.args, spawnOptions); ++ const handle = spawnProcess(command.command, command.args, spawnOptions); + handle.on("error", error => { + resume(Effect.fail(toPlatformError("spawn", error, command))); + }); +@@ -553,7 +557,12 @@ const make = /*#__PURE__*/Effect.gen(function* () { + * @category layers + * @since 4.0.0 + */ +-export const layer = /*#__PURE__*/Layer.effect(ChildProcessSpawner, make); ++export const layer = /*#__PURE__*/Layer.effect(ChildProcessSpawner, /*#__PURE__*/makeWithSpawn(NodeChildProcess.spawn)); ++/** ++ * `layer` with a replacement for `child_process.spawn` that returns a ++ * `ChildProcess`-compatible object. ++ */ ++export const layerWithSpawn = spawnProcess => Layer.effect(ChildProcessSpawner, makeWithSpawn(spawnProcess)); + /** + * Flattens a `Command` into an array of `StandardCommand`s along with pipe + * options for each connection. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91d47694eec8b..a471816d7735b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,7 +144,7 @@ packageExtensionsChecksum: sha256-FYDaKefjFcda1+vgiq8yMpZC4ehE5CyLi/bsCGLgTtY= patchedDependencies: '@clerk/expo@4.6.8': b8b22ab733de5bea846ad9e88046c10582352fe9ad86f314f368b2a823f0f281 - '@effect/platform-node-shared@4.0.2': deaf42ac8518671bcaba11355411f3dced41d7aeca6778e96ce5982773639535 + '@effect/platform-node-shared@4.0.2': 536ea2f8120e52e7160467b9f590db575ef019536cb9cc05326518966309d05f '@effect/vitest@4.0.2': 595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9 '@expo/metro-config@58.0.6': 90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 @@ -612,7 +612,7 @@ importers: version: 4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/platform-node-shared': specifier: 4.0.2 - version: 4.0.2(patch_hash=deaf42ac8518671bcaba11355411f3dced41d7aeca6778e96ce5982773639535)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6) + version: 4.0.2(patch_hash=536ea2f8120e52e7160467b9f590db575ef019536cb9cc05326518966309d05f)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6) '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8) @@ -13779,7 +13779,7 @@ snapshots: transitivePeerDependencies: - encoding - '@effect/platform-node-shared@4.0.2(patch_hash=deaf42ac8518671bcaba11355411f3dced41d7aeca6778e96ce5982773639535)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6)': + '@effect/platform-node-shared@4.0.2(patch_hash=536ea2f8120e52e7160467b9f590db575ef019536cb9cc05326518966309d05f)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.2 effect: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) @@ -13790,7 +13790,7 @@ snapshots: '@effect/platform-node@4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(redis@6.2.1)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.2(patch_hash=deaf42ac8518671bcaba11355411f3dced41d7aeca6778e96ce5982773639535)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6) + '@effect/platform-node-shared': 4.0.2(patch_hash=536ea2f8120e52e7160467b9f590db575ef019536cb9cc05326518966309d05f)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6) effect: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) redis: 6.2.1 undici: 8.11.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0c0c52c20814f..73be846320a1b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -289,7 +289,9 @@ patchedDependencies: expo-audio@58.0.4: patches/expo-audio@58.0.4.patch "@clerk/expo@4.6.8": patches/@clerk__expo@4.6.8.patch # An input fd pipe keeps an error listener after its writer stops, so a child that exits with - # unread input (the desktop backend quit during a cold start) does not crash the parent. + # unread input (the desktop backend quit during a cold start) does not crash the parent. The + # spawner also takes the spawn function as a parameter so the server can start children from its + # spawn host. "@effect/platform-node-shared@4.0.2": patches/@effect__platform-node-shared@4.0.2.patch "@effect/vitest@4.0.2": patches/@effect__vitest@4.0.2.patch "@expo/metro-config@58.0.6": patches/@expo__metro-config@58.0.6.patch From c4dc04c017b2091bc24cd58ff1a39d58911b7c8f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 00:57:49 +0000 Subject: [PATCH 002/241] fix(server): sync the spawn host channel ref when a new host starts --- apps/server/src/process/SpawnHost.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/process/SpawnHost.ts b/apps/server/src/process/SpawnHost.ts index d3a2ea5e06395..ab7e83cfd8540 100644 --- a/apps/server/src/process/SpawnHost.ts +++ b/apps/server/src/process/SpawnHost.ts @@ -272,6 +272,7 @@ class SpawnHostClient { } this.#host = host; host.unref(); + this.#syncChannelRef(); this.#readyTimer = setTimeout(() => { host.kill("SIGKILL"); this.#lose(host, new Error("The spawn host did not start in time.")); From 8478f658e8ba557e378f8b5558eddd7512b3a230 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 12:48:30 +0000 Subject: [PATCH 003/241] fix(server): spawn host reads the platform from the HostProcess module --- apps/server/src/process/SpawnHost.ts | 4 ++-- apps/server/src/processRunner.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/process/SpawnHost.ts b/apps/server/src/process/SpawnHost.ts index ab7e83cfd8540..540bdd354b6cd 100644 --- a/apps/server/src/process/SpawnHost.ts +++ b/apps/server/src/process/SpawnHost.ts @@ -18,7 +18,7 @@ import type * as NodeStream from "node:stream"; import * as NodeChildProcessSpawner from "@effect/platform-node-shared/NodeChildProcessSpawner"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; import type { SpawnHostError, SpawnHostEvent, SpawnHostSpawnRequest } from "./spawnHostProcess.ts"; @@ -366,7 +366,7 @@ export const layerHost = ( export const layer = Layer.unwrap( Effect.gen(function* () { - if ((yield* HostProcessPlatform) !== "linux") return NodeChildProcessSpawner.layer; + if ((yield* HostProcess.Platform) !== "linux") return NodeChildProcessSpawner.layer; const invocation = yield* resolveSelfInvocation(); const runFork = Effect.runForkWith(yield* Effect.context()); return layerHost( diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 8bb6e07f2d136..4380be9ec305b 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -409,7 +409,7 @@ describe("runProcess", () => { ); }); -const hostPlatform = HostProcessPlatform.defaultValue(); +const hostPlatform = HostProcess.Platform.defaultValue(); describe.skipIf(hostPlatform === "win32")("runProcess through the spawn host", () => { const hostFailures: Array = []; From 34463e073cd1dc015af89c4e724a6df91a9fa1ba Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 07:18:57 +0000 Subject: [PATCH 004/241] perf(server): load provider adapters and optional services on first use --- apps/server/src/cloud/CloudLink.ts | 1346 +--------- apps/server/src/cloud/CloudLinkLive.ts | 1319 +++++++++ apps/server/src/mcpApps/McpAppRequests.ts | 229 +- apps/server/src/mcpApps/McpAppRequestsLive.ts | 215 ++ .../Adapters/ClaudeAdapterV2.test.ts | 3 +- .../Adapters/ClaudeAdapterV2.testkit.ts | 15 +- .../Adapters/ClaudeAdapterV2.ts | 25 +- .../Adapters/CodexAdapterV2.test.ts | 60 +- .../Adapters/CodexAdapterV2.testkit.ts | 42 +- .../Adapters/CodexAdapterV2.ts | 24 +- .../Adapters/OpenCodeAdapterV2.testkit.ts | 2 +- .../src/orchestration-v2/ProjectionStore.ts | 32 +- apps/server/src/preview/ServerBrowser.ts | 2333 +--------------- apps/server/src/preview/ServerBrowserLive.ts | 2314 ++++++++++++++++ .../src/provider/ClaudeAgentSdkQueryRunner.ts | 58 + apps/server/src/provider/ClaudeProvider.ts | 14 +- .../provider/CodexAppServerClientFactory.ts | 53 + apps/server/src/provider/CodexProvider.ts | 5 +- .../src/provider/Drivers/AntigravityDriver.ts | 56 +- .../src/provider/Drivers/ClaudeDriver.ts | 52 +- .../src/provider/Drivers/CodexDriver.test.ts | 12 +- .../src/provider/Drivers/CodexDriver.ts | 52 +- .../provider/Drivers/CodexManagedProvider.ts | 34 +- ...viderOrchestrationAdapterInfrastructure.ts | 12 +- apps/server/src/provider/builtInDrivers.ts | 12 +- apps/server/src/terminal/Manager.ts | 2 +- apps/server/src/usage/UsageService.test.ts | 6 + apps/server/src/usage/UsageService.ts | 930 +------ apps/server/src/usage/UsageServiceLive.ts | 896 +++++++ packages/provider-acp-registry/package.json | 4 + .../src/server/driver.ts | 39 +- packages/provider-core/package.json | 4 + .../provider-core/src/server/lazyAdapter.ts | 50 + .../src/server/managedProvider.test.ts | 74 + packages/provider-cursor/package.json | 8 + .../src/server/CursorAgentSdk.test.ts | 20 +- .../src/server/CursorAgentSdk.ts | 7 +- .../provider-cursor/src/server/CursorSdk.ts | 16 +- .../src/server/CursorSdkCatalog.ts | 7 +- .../provider-cursor/src/server/auth.test.ts | 3 +- packages/provider-cursor/src/server/auth.ts | 4 +- .../provider-cursor/src/server/driver.test.ts | 3 +- packages/provider-cursor/src/server/driver.ts | 85 +- packages/provider-cursor/src/server/sdk.ts | 11 +- .../src/server/textGeneration.test.ts | 6 +- .../src/server/textGeneration.ts | 4 +- packages/provider-grok/package.json | 4 + packages/provider-grok/src/server/driver.ts | 39 +- packages/provider-muse/package.json | 4 + packages/provider-muse/src/server/driver.ts | 24 +- packages/provider-opencode/package.json | 4 + .../src/server/OpenCodeRuntime.ts | 93 +- .../src/server/adapter.test.ts | 8 +- .../provider-opencode/src/server/adapter.ts | 2 +- .../src/server/driver.test.ts | 20 +- .../provider-opencode/src/server/driver.ts | 91 +- .../src/server/status.test.ts | 8 +- .../provider-opencode/src/server/status.ts | 17 +- .../src/server/textGeneration.test.ts | 8 +- .../src/server/textGeneration.ts | 2 +- .../src/server/v2/OpenCode2Client.ts | 161 +- .../src/server/v2/OpenCode2ClientLive.ts | 148 ++ packages/provider-pi/package.json | 4 + packages/provider-pi/src/server/driver.ts | 39 +- packages/shared/package.json | 4 + packages/shared/src/lazyService.ts | 14 + .../src/server/GitHubApi.ts | 417 +-- .../src/server/GitHubApiLive.ts | 432 +++ .../src/server/GitHubPullRequestApi.test.ts | 17 +- .../src/server/GitHubPullRequestApi.ts | 2352 +---------------- .../src/server/GitHubPullRequestApiLive.ts | 2281 ++++++++++++++++ 71 files changed, 8725 insertions(+), 7966 deletions(-) create mode 100644 apps/server/src/cloud/CloudLinkLive.ts create mode 100644 apps/server/src/mcpApps/McpAppRequestsLive.ts create mode 100644 apps/server/src/preview/ServerBrowserLive.ts create mode 100644 apps/server/src/provider/ClaudeAgentSdkQueryRunner.ts create mode 100644 apps/server/src/provider/CodexAppServerClientFactory.ts create mode 100644 apps/server/src/usage/UsageServiceLive.ts create mode 100644 packages/provider-core/src/server/lazyAdapter.ts create mode 100644 packages/provider-opencode/src/server/v2/OpenCode2ClientLive.ts create mode 100644 packages/shared/src/lazyService.ts create mode 100644 packages/source-control-github/src/server/GitHubApiLive.ts create mode 100644 packages/source-control-github/src/server/GitHubPullRequestApiLive.ts diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 6d6d137deda16..d9d61f3624a27 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -1,124 +1,39 @@ +import { cacheInOwnerScope } from "@t3tools/shared/lazyService"; /** * The T3 Connect link lifecycle of this environment: linking it to the relay, * applying and reading the link, unlinking, answering the relay's signed health * and mint requests, and keeping the managed tunnel registered, recovered and * released. HTTP handlers, server startup and shutdown all go through it. */ -// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createPublicKey. -import * as NodeCrypto from "node:crypto"; -import { - AuthStandardClientScopes, - type EnvironmentCloudLinkStateResult, - type EnvironmentCloudPreferencesRequest, - type EnvironmentCloudRelayConfigResult, +import type { + EnvironmentCloudLinkStateResult, + EnvironmentCloudPreferencesRequest, + EnvironmentCloudRelayConfigResult, } from "@t3tools/contracts"; -import { - RelayCloudEnvironmentHealthProofPayload, - type RelayCloudEnvironmentHealthRequest, - RelayCloudMintCredentialProofPayload, - type RelayCloudMintCredentialRequest, - type RelayEnvironmentConfigRequest, - type RelayEnvironmentHealthResponse, - type RelayEnvironmentHealthResponseProofPayload, - type RelayEnvironmentLinkProof, - type RelayEnvironmentLinkProofPayload, - RelayEnvironmentLinkChallengeResponse, - RelayEnvironmentLinkResponse, - type RelayEnvironmentMintResponse, - type RelayEnvironmentMintResponseProofPayload, - type RelayLinkProofRequest, - type RelayManagedEndpointOrigin, - type RelayManagedEndpointRecoveryProofPayload, - RelayManagedEndpointRecoveryRegistrationResponse, - RelayManagedEndpointRecoveryResponse, - type RelayManagedEndpointRuntimeConfig, - RelayOkResponse, +import type { + RelayCloudEnvironmentHealthRequest, + RelayCloudMintCredentialRequest, + RelayEnvironmentConfigRequest, + RelayEnvironmentHealthResponse, + RelayEnvironmentLinkProof, + RelayEnvironmentMintResponse, + RelayLinkProofRequest, + RelayManagedEndpointRuntimeConfig, } from "@t3tools/contracts/relay"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; -import { - normalizeRelayIssuer, - RELAY_HEALTH_REQUEST_TYP, - RELAY_HEALTH_RESPONSE_TYP, - RELAY_LINK_PROOF_TYP, - RELAY_MANAGED_TUNNEL_RECOVERY_TYP, - RELAY_MINT_REQUEST_TYP, - RELAY_MINT_RESPONSE_TYP, - signRelayJwt, - verifyRelayJwt, -} from "@t3tools/shared/relayJwt"; -import { isSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import type * as PlatformError from "effect/PlatformError"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; -import { - HttpClient, - HttpClientRequest, - HttpClientResponse, - type HttpServerRequest, -} from "effect/http"; +import type { HttpServerRequest } from "effect/http"; import type * as HttpClientError from "effect/http/HttpClientError"; -import * as HttpServer from "effect/http/HttpServer"; -import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import type * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ServerConfig from "../config.ts"; -import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts"; -import { makeRelayEnvironmentClient } from "../relay/relayEnvironmentClient.ts"; -import { - type CliDesiredLinkMode, - readCliDesiredCloudLink, - readCliDesiredLinkMode, - setCliDesiredCloudLink, -} from "./CliState.ts"; -import * as CliTokenManager from "./CliTokenManager.ts"; -import { - CLOUD_ENDPOINT_CONFIRMED_ORIGIN, - CLOUD_ENDPOINT_RUNTIME_CONFIG, - CLOUD_LINKED_USER_ID, - CLOUD_MINT_PUBLIC_KEY, - decodeConfirmedOrigin, - decodeRuntimeConfig, - encodeConfirmedOriginJson, - encodeEndpointRuntimeConfigJson, - HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, - PUBLISH_AGENT_ACTIVITY_SECRET, - readRelayConnection, - RELAY_ENVIRONMENT_CREDENTIAL_SECRET, - RELAY_ISSUER_SECRET, - RELAY_URL_SECRET, -} from "./config.ts"; -import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; -import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; -import { relayUrlConfig } from "./publicConfig.ts"; -import { - filterRelayResponse, - relayRequestError, - type RelayRequestError, - shouldRetryRelayRequest, -} from "./relayResponse.ts"; -import { - hasBoundedCloudProofLifetime, - hasExactScope, - hasForwardedAuthorityHeaders, - isAllowedEndpointOrigin, - isSupportedLinkProviderKind, - linkProofScopes, - managedEndpointRuntimeConfigsMatch, - parseManagedEndpointLocalOrigin, - requestAbsoluteUrl, -} from "./linkChecks.ts"; -import { desktopUpdateRestartPending, pendingUpdateHandoffExists } from "./updateHandoff.ts"; +import type { CliDesiredLinkMode } from "./CliState.ts"; +import type * as CliTokenManager from "./CliTokenManager.ts"; +import type * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; +import { type RelayRequestError, shouldRetryRelayRequest } from "./relayResponse.ts"; const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; @@ -131,7 +46,6 @@ export const CLOUD_REPLAY_MARKER_PREFIXES = [ CLOUD_HEALTH_NONCE_PREFIX, CLOUD_HEALTH_JTI_PREFIX, ] as const; -const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2); const RELAY_CONFIG_FIELD_MESSAGES = { relayUrl: "Relay URL must be a secure absolute HTTPS URL.", relayIssuer: "Relay issuer must be a secure absolute HTTPS URL.", @@ -292,11 +206,6 @@ export class CloudLinkInternalError extends Schema.TaggedError - (cause: unknown): Effect.Effect => - Effect.fail(new CloudLinkInternalError({ operation, cause })); - const isPermanentLinkError = Schema.is( Schema.Union([ CloudLinkRelayConfigInvalidError, @@ -315,23 +224,6 @@ export const shouldRetryCloudLink = (error: unknown): boolean => shouldRetryRelayRequest(error) && !isPermanentLinkError(error); /** A failed rollback leaves a setting changed; it is logged, not hidden. */ -const rollbackFailed = (cause: unknown) => - Effect.logWarning("Could not roll back a T3 Connect preference", { cause }); - -const requireRelayUrl = relayUrlConfig.pipe( - Effect.mapError( - (cause) => new CloudLinkInternalError({ operation: "relay-url-unconfigured", cause }), - ), -); - -function bytesToString(bytes: Uint8Array): string { - return new TextDecoder().decode(bytes); -} - -function stringToBytes(value: string): Uint8Array { - return new TextEncoder().encode(value); -} - /** Records each one-shot marker; false when any already existed, so the request is a replay. */ export function consumeCloudReplayGuards(input: { readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; @@ -353,62 +245,6 @@ export function consumeCloudReplayGuards(input: { ).pipe(Effect.map((created) => created.every(Boolean))); } -function validateCloudMintPublicKey( - publicKey: string, -): Effect.Effect { - const invalid = new CloudLinkRelayConfigInvalidError({ field: "cloudMintPublicKey" }); - return Effect.try({ - try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), - catch: () => invalid, - }).pipe( - Effect.flatMap((key) => - key.asymmetricKeyType === "ed25519" ? Effect.void : Effect.fail(invalid), - ), - ); -} - -function invalidRelayConfigField( - payload: RelayEnvironmentConfigRequest, -): CloudLinkRelayConfigInvalidError["field"] | null { - if (!isSecureRelayUrl(payload.relayUrl)) return "relayUrl"; - if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) { - return "relayIssuer"; - } - if (payload.environmentCredential.trim().length === 0) return "environmentCredential"; - if (payload.cloudUserId.trim().length === 0) return "cloudUserId"; - return null; -} - -function validateRelayConfigPayload( - payload: RelayEnvironmentConfigRequest, -): Effect.Effect { - const field = invalidRelayConfigField(payload); - return field === null - ? Effect.void - : Effect.fail(new CloudLinkRelayConfigInvalidError({ field })); -} - -function normalizePemForSignedPayload(value: string): string { - return value.trim(); -} - -const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentHealthProofPayload); -const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload); - -type ManagedTunnelRecoveryProofInput = { - readonly environmentId: RelayManagedEndpointRecoveryProofPayload["environmentId"]; - readonly cloudUserId: string; - readonly relayUrl: string; -} & ( - | { - readonly action: "register"; - readonly tunnelId: string; - readonly origin: RelayManagedEndpointOrigin; - } - | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin } -); - -/** Every failure CloudLink constructs itself. */ export type CloudLinkError = | CloudLinkAccountMismatchError | CloudLinkAuthorizationMissingError @@ -422,7 +258,7 @@ export type CloudLinkError = | CloudLinkTunnelSupersededError; /** Failures of the link work that talks to the relay: linking, recovery and shutdown. */ -type CloudLinkBackgroundError = +export type CloudLinkBackgroundError = | CloudLinkEndpointUnavailableError | CloudLinkInternalError | CloudLinkOriginInvalidError @@ -431,7 +267,7 @@ type CloudLinkBackgroundError = | Schema.SchemaError | PlatformError.PlatformError; -type ManagedTunnelRegistration = +export type ManagedTunnelRegistration = | { readonly status: "not_linked" | "superseded" } | { readonly status: "recovery_required"; readonly config: RelayManagedEndpointRuntimeConfig } | { @@ -566,1112 +402,42 @@ export class CloudLink extends Context.Service< >()("t3/cloud/CloudLink") {} const make = Effect.gen(function* () { - const secrets = yield* ServerSecretStore.ServerSecretStore; - const environment = yield* ServerEnvironment.ServerEnvironment; - const endpointRuntime = yield* ManagedEndpointRuntime.CloudManagedEndpointRuntime; - const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; - const cliTokenManager = yield* CliTokenManager.CloudCliTokenManager; - const httpClient = yield* HttpClient.HttpClient; - const httpServer = yield* HttpServer.HttpServer; - const awarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; - const crypto = yield* Crypto.Crypto; - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const withSecrets = Effect.provideService(ServerSecretStore.ServerSecretStore, secrets); - const withRuntimeFiles = ( - effect: Effect.Effect, - ) => - effect.pipe( - Effect.provideService(ServerConfig.ServerConfig, config), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); - - const validateLinkedCloudUser = ( - cloudUserId: string, - ): Effect.Effect => - secrets.get(CLOUD_LINKED_USER_ID).pipe( - Effect.mapError( - (cause) => - new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ - cause, - }), - ), - Effect.flatMap((existing) => { - if (Option.isNone(existing)) { - return Effect.void; - } - const existingCloudUserId = bytesToString(existing.value); - return existingCloudUserId === cloudUserId - ? Effect.void - : Effect.fail(new CloudLinkAccountMismatchError({})); - }), - ); - - const readInstalledCloudUserId: Effect.Effect = - secrets.get(CLOUD_LINKED_USER_ID).pipe( - Effect.mapError( - (cause) => - new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ - cause, - }), - ), - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})), - ), - ); - - const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function* ( - request: RelayLinkProofRequest, - requestUrl: string, - ) { - const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); - if ( - !isSupportedLinkProviderKind(request) || - !isAllowedEndpointOrigin({ - origin: request.origin, - requestUrl, - }) - ) { - return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" }); - } - const now = yield* DateTime.now; - const expiresAt = DateTime.add(now, { minutes: 5 }); - const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); - const descriptor = yield* environment.getDescriptor; - const payload = { - iss: `t3-env:${descriptor.environmentId}`, - aud: normalizeRelayIssuer(request.relayIssuer), - sub: descriptor.environmentId, - jti: yield* crypto.randomUUIDv4, - iat: nowSeconds, - exp: Math.floor(expiresAt.epochMilliseconds / 1_000), - challenge: request.challenge, - descriptor, - environmentId: descriptor.environmentId, - environmentPublicKey: normalizePemForSignedPayload(keyPair.publicKey), - endpoint: request.endpoint, - origin: request.origin, - scopes: linkProofScopes(request), - } satisfies RelayEnvironmentLinkProofPayload; - return yield* signRelayJwt({ - privateKey: keyPair.privateKey, - typ: RELAY_LINK_PROOF_TYP, - payload, - }).pipe( - Effect.mapError( - (cause) => - new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ - cause, - }), - ), - ); - }); - - const linkProof = Effect.fn("environment.cloud.linkProof")( - function* (request: RelayLinkProofRequest, httpRequest: HttpServerRequest.HttpServerRequest) { - const requestUrl = requestAbsoluteUrl(httpRequest); - if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) { - return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" }); - } - const proof = yield* makeCloudLinkProof(request, requestUrl); - return proof satisfies RelayEnvironmentLinkProof; - }, - Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("generate-link-proof")), - Effect.catchTags({ PlatformError: internalError("generate-link-proof") }), - ); - - const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")( - function* (input: { - readonly config: RelayManagedEndpointRuntimeConfig; - readonly configJson: string; - readonly origin: RelayManagedEndpointOrigin; - }) { - return yield* endpointRuntime.withLinkStateLock( - Effect.gen(function* () { - const currentConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); - if ( - Option.isNone(currentConfig) || - bytesToString(currentConfig.value) !== input.configJson - ) { - return null; - } - const status = yield* endpointRuntime.applyConfig(input.config); - if (status.status !== "running") { - return yield* new CloudLinkEndpointUnavailableError({ - reason: "runtime-not-started", - endpointRuntimeStatus: status, - }); - } - const marker = yield* encodeConfirmedOriginJson({ - config: input.config, - origin: input.origin, - }); - yield* secrets.set(CLOUD_ENDPOINT_CONFIRMED_ORIGIN, stringToBytes(marker)); - return status; - }), - ); - }, - ); - - const activateManagedTunnelWithRetry = ( - input: { - readonly config: RelayManagedEndpointRuntimeConfig; - readonly configJson: string; - readonly origin: RelayManagedEndpointOrigin; - }, - retryRuntimeFailures: boolean, - ) => { - const activate = activateManagedTunnel(input); - return retryRuntimeFailures - ? activate.pipe( - Effect.retry({ - while: (error) => - error._tag === "CloudLinkEndpointUnavailableError" && - ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus( - error.endpointRuntimeStatus, - ), - schedule: Schedule.exponential("1 second").pipe( - Schedule.modifyDelay(({ duration }) => - Effect.succeed(Duration.min(duration, Duration.seconds(30))), - ), - Schedule.jittered, - ), - }), - ) - : activate; - }; - - const startManagedTunnelIfOriginConfirmed = Effect.fn( - "environment.cloud.startManagedCloudTunnelIfOriginConfirmed", - )(function* (localOrigin: string, options?: { readonly requireConfirmedOrigin?: boolean }) { - const requireConfirmedOrigin = options?.requireConfirmedOrigin ?? true; - const parsedOrigin = yield* Effect.try({ - try: () => parseManagedEndpointLocalOrigin(localOrigin), - catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), - }); - return yield* endpointRuntime.withLinkStateLock( - Effect.gen(function* () { - const [runtimeBytes, markerBytes] = yield* Effect.all([ - secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), - secrets.get(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), - ]); - if (Option.isNone(runtimeBytes)) return false; - const config = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeBytes.value))); - if (config === null || config.providerKind !== "cloudflare_tunnel") return false; - // With the marker required, only a config the relay already confirmed on - // this port may start. Without it, startup is falling back after the - // relay stayed unreachable: an unconfirmed origin may send traffic to a - // stale port, but that beats no remote access at all. - if (requireConfirmedOrigin) { - if (Option.isNone(markerBytes)) return false; - const marker = Option.getOrNull(decodeConfirmedOrigin(bytesToString(markerBytes.value))); - if ( - marker === null || - !managedEndpointRuntimeConfigsMatch(marker.config, config) || - marker.origin.localHttpHost !== parsedOrigin.origin.localHttpHost || - marker.origin.localHttpPort !== parsedOrigin.origin.localHttpPort - ) { - return false; - } - } - const status = yield* endpointRuntime.applyConfig(config); - if (status.status !== "running") { - return yield* new CloudLinkEndpointUnavailableError({ - reason: "runtime-not-started", - endpointRuntimeStatus: status, - }); - } - return true; - }), - ); - }); - - const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( - payload: RelayEnvironmentConfigRequest, - options?: { - readonly lockHeld?: boolean; - readonly confirmedOrigin?: RelayManagedEndpointOrigin; - }, - ) { - const apply = Effect.gen(function* () { - yield* validateRelayConfigPayload(payload); - yield* validateLinkedCloudUser(payload.cloudUserId); - yield* validateCloudMintPublicKey(payload.cloudMintPublicKey); - // Reject unsupported runtimes before touching the connector so a bad - // payload cannot stop a healthy tunnel on its way to a 503. - if ( - payload.endpointRuntime !== null && - payload.endpointRuntime.providerKind !== "cloudflare_tunnel" - ) { - return yield* new CloudLinkEndpointUnavailableError({ - reason: "runtime-not-started", - endpointRuntimeStatus: { - status: "unsupported", - providerKind: payload.endpointRuntime.providerKind, - }, - }); - } - yield* endpointRuntime.applyConfig(null); - yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); - - yield* secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl)); - yield* secrets.set( - RELAY_ISSUER_SECRET, - stringToBytes(payload.relayIssuer ?? payload.relayUrl), - ); - yield* secrets.set(CLOUD_LINKED_USER_ID, stringToBytes(payload.cloudUserId)); - yield* secrets.set( - RELAY_ENVIRONMENT_CREDENTIAL_SECRET, - stringToBytes(payload.environmentCredential), - ); - yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey)); - yield* awarenessRelay.requestCatchUp(); - if (payload.endpointRuntime) { - const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); - yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(endpointRuntimeJson)); - } else { - yield* secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); - } - if (payload.endpointRuntime === null || options?.confirmedOrigin === undefined) { - return { - ok: true, - endpointRuntimeStatus: { status: "disabled" }, - } satisfies EnvironmentCloudRelayConfigResult; - } - const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(payload.endpointRuntime); - if (endpointRuntimeStatus.status !== "running") { - return yield* new CloudLinkEndpointUnavailableError({ - reason: "runtime-not-started", - endpointRuntimeStatus, - }); - } - const marker = yield* encodeConfirmedOriginJson({ - config: payload.endpointRuntime, - origin: options.confirmedOrigin, - }); - yield* secrets.set(CLOUD_ENDPOINT_CONFIRMED_ORIGIN, stringToBytes(marker)); - return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; - }); - return yield* options?.lockHeld ? apply : endpointRuntime.withLinkStateLock(apply); - }); - - const relayClientRequest = (input: { - readonly url: string; - readonly token: string; - readonly payload: unknown; - readonly schema: Schema.Decoder; - readonly timeout?: Duration.Input; - }) => - HttpClientRequest.post(input.url).pipe( - HttpClientRequest.bearerToken(input.token), - HttpClientRequest.bodyJson(input.payload), - Effect.flatMap(httpClient.execute), - Effect.flatMap(filterRelayResponse), - Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), - Effect.timeout(input.timeout ?? "10 seconds"), - Effect.mapError(relayRequestError), - withRelayClientTracing, - ); - - const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")( - function* (localOrigin: string) { - const parsedOrigin = yield* Effect.try({ - try: () => parseManagedEndpointLocalOrigin(localOrigin), - catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), - }); - const token = yield* cliTokenManager.getExisting.pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(new CloudLinkAuthorizationMissingError({})), - onSome: Effect.succeed, - }), - ), - ); - const mode = yield* readCliDesiredLinkMode.pipe(withSecrets); - const managedTunnelsEnabled = mode !== "publish_only"; - const relayUrl = yield* requireRelayUrl; - const challenge = yield* relayClientRequest({ - url: `${relayUrl}/v1/client/environment-link-challenges`, - token: token.accessToken, - payload: { - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled, - }, - schema: RelayEnvironmentLinkChallengeResponse, - }); - const proof = yield* makeCloudLinkProof( - { - challenge: challenge.challenge, - relayIssuer: relayUrl, - endpoint: { - httpBaseUrl: parsedOrigin.httpBaseUrl, - wsBaseUrl: parsedOrigin.wsBaseUrl, - providerKind: managedTunnelsEnabled ? "cloudflare_tunnel" : "manual", - }, - origin: parsedOrigin.origin, - }, - parsedOrigin.httpBaseUrl, - ); - const link = yield* relayClientRequest({ - url: `${relayUrl}/v1/client/environment-links`, - token: token.accessToken, - payload: { - proof, - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled, - }, - schema: RelayEnvironmentLinkResponse, - timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, - }); - yield* setCliDesiredCloudLink(true, mode).pipe(withSecrets); - yield* applyCloudRelayConfig( - { - relayUrl, - relayIssuer: link.relayIssuer, - cloudUserId: link.cloudUserId, - environmentCredential: link.environmentCredential, - cloudMintPublicKey: link.cloudMintPublicKey, - endpointRuntime: link.endpointRuntime, - }, - { - lockHeld: true, - confirmedOrigin: parsedOrigin.origin, - }, - ); - // Callers decide on managed tunnel recovery from the mode this link - // actually used, not from a value read before the relay round trip. - return mode; - }, - Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-desired-link")), - ); - - const reconcileDesiredLink = Effect.fn("environment.cloud.reconcileDesiredLink")(function* ( - localOrigin: string, - ) { - return yield* endpointRuntime.withLinkStateLock(reconcileDesiredCloudLinkWith(localOrigin)); - }); - - const reconcileDesiredLinkIfStillDesired = Effect.fn( - "environment.cloud.reconcileDesiredLinkIfStillDesired", - )(function* (localOrigin: string) { - return yield* endpointRuntime.withLinkStateLock( - Effect.gen(function* () { - if (!(yield* readCliDesiredCloudLink.pipe(withSecrets))) { - return null; - } - return yield* reconcileDesiredCloudLinkWith(localOrigin); - }), - ); - }); - - const makeManagedTunnelRecoveryProof = Effect.fn( - "environment.cloud.makeManagedTunnelRecoveryProof", - )(function* (input: ManagedTunnelRecoveryProofInput) { - const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); - const configuredIssuer = yield* secrets.get(RELAY_ISSUER_SECRET); - const now = yield* DateTime.now; - const issuedAt = Math.floor(now.epochMilliseconds / 1_000); - const claims = { - iss: `t3-env:${input.environmentId}`, - aud: normalizeRelayIssuer( - Option.isSome(configuredIssuer) ? bytesToString(configuredIssuer.value) : input.relayUrl, - ), - sub: input.environmentId, - jti: yield* crypto.randomUUIDv4, - iat: issuedAt, - exp: issuedAt + 60, - environmentId: input.environmentId, - cloudUserId: input.cloudUserId, - }; - const payload = - input.action === "register" - ? { - ...claims, - action: "register" as const, - tunnelId: input.tunnelId, - origin: input.origin, - } - : { ...claims, action: "recover" as const, origin: input.origin }; - - return yield* signRelayJwt({ - privateKey: keyPair.privateKey, - typ: RELAY_MANAGED_TUNNEL_RECOVERY_TYP, - payload, - }).pipe( - Effect.mapError( - (cause) => new CloudLinkInternalError({ operation: "sign-recovery-proof", cause }), - ), - ); - }); - - const registerManagedTunnelRecovery = Effect.fn( - "environment.cloud.registerManagedCloudTunnelRecovery", - )(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) { - const [runtimeConfig, relayUrl, cloudUserId, environmentCredential] = yield* Effect.all([ - secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), - secrets.get(RELAY_URL_SECRET), - secrets.get(CLOUD_LINKED_USER_ID), - secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), - ]); - if ( - Option.isNone(runtimeConfig) || - Option.isNone(relayUrl) || - Option.isNone(cloudUserId) || - Option.isNone(environmentCredential) - ) { - return { status: "not_linked" as const }; - } - - const config = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeConfig.value))); - if (config?.providerKind !== "cloudflare_tunnel") { - return { status: "not_linked" as const }; - } - - const parsedOrigin = yield* Effect.try({ - try: () => parseManagedEndpointLocalOrigin(localOrigin), - catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), - }); - if (config.tunnelId === undefined) { - return { status: "recovery_required" as const, config }; - } - const origin = parsedOrigin.origin; - const environmentId = yield* environment.getEnvironmentId; - const relayUrlValue = bytesToString(relayUrl.value); - const cloudUserIdValue = bytesToString(cloudUserId.value); - const proof = yield* makeManagedTunnelRecoveryProof({ - action: "register", - environmentId, - cloudUserId: cloudUserIdValue, - relayUrl: relayUrlValue, - tunnelId: config.tunnelId, - origin, - }); - const registered = yield* relayClientRequest({ - url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`, - token: bytesToString(environmentCredential.value), - payload: { - cloudUserId: cloudUserIdValue, - tunnelId: config.tunnelId, - origin, - proof, - }, - schema: RelayManagedEndpointRecoveryRegistrationResponse, - }); - if (registered.status === "recovery_required") { - return { status: registered.status, config }; - } - const endpointRuntimeStatus = yield* activateManagedTunnelWithRetry( - { - config, - configJson: bytesToString(runtimeConfig.value), - origin, - }, - options?.retryRuntimeFailures === true, - ); - return endpointRuntimeStatus === null - ? { status: "superseded" as const } - : { status: "ready" as const, endpointRuntimeStatus }; - }); - - const recoverManagedTunnel = Effect.fn("environment.cloud.recoverManagedCloudTunnel")(function* ( - localOrigin: string, - expectedConfig?: RelayManagedEndpointRuntimeConfig, - options?: { readonly retryRuntimeFailures?: boolean }, - ) { - const [runtimeConfig, relayUrl, cloudUserId, environmentCredential] = yield* Effect.all([ - secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), - secrets.get(RELAY_URL_SECRET), - secrets.get(CLOUD_LINKED_USER_ID), - secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), - ]); - if ( - Option.isNone(runtimeConfig) || - Option.isNone(relayUrl) || - Option.isNone(cloudUserId) || - Option.isNone(environmentCredential) - ) { - return false; - } - if (expectedConfig !== undefined) { - const current = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeConfig.value))); - if ( - current === null || - current.providerKind !== expectedConfig.providerKind || - current.connectorToken !== expectedConfig.connectorToken || - current.tunnelId !== expectedConfig.tunnelId || - current.tunnelName !== expectedConfig.tunnelName - ) { - return false; - } - } - - const parsedOrigin = yield* Effect.try({ - try: () => parseManagedEndpointLocalOrigin(localOrigin), - catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), - }); - - const environmentId = yield* environment.getEnvironmentId; - const relayUrlValue = bytesToString(relayUrl.value); - const cloudUserIdValue = bytesToString(cloudUserId.value); - const origin = parsedOrigin.origin; - const proof = yield* makeManagedTunnelRecoveryProof({ - action: "recover", - environmentId, - cloudUserId: cloudUserIdValue, - relayUrl: relayUrlValue, - origin, - }); - const recovered = yield* relayClientRequest({ - url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`, - token: bytesToString(environmentCredential.value), - payload: { - cloudUserId: cloudUserIdValue, - origin, - proof, - }, - schema: RelayManagedEndpointRecoveryResponse, - timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, - }); - if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") { - return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" }); - } - - const encoded = yield* encodeEndpointRuntimeConfigJson(recovered.endpointRuntime).pipe( - Effect.mapError( - (cause) => new CloudLinkInternalError({ operation: "persist-recovered-tunnel", cause }), - ), - ); - const stored = yield* endpointRuntime.withLinkStateLock( - Effect.gen(function* () { - const currentConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); - if ( - Option.isNone(currentConfig) || - bytesToString(currentConfig.value) !== bytesToString(runtimeConfig.value) - ) { - return false; - } - yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded)); - yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); - return true; - }), - ); - if (!stored) return false; - const status = yield* activateManagedTunnelWithRetry( - { - config: recovered.endpointRuntime, - configJson: encoded, - origin, - }, - options?.retryRuntimeFailures === true, - ); - return status !== null; - }); - - const applyRelayConfig = Effect.fn("environment.cloud.relayConfig")( - function* (payload: RelayEnvironmentConfigRequest) { - const result = yield* applyCloudRelayConfig(payload); - if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") { - const address = httpServer.address; - if (typeof address === "string" || !("port" in address)) { - return yield* new CloudLinkInternalError({ operation: "resolve-server-origin" }); - } - const registration = yield* registerManagedTunnelRecovery( - `http://127.0.0.1:${address.port}`, - ).pipe( - Effect.retry({ - times: 2, - while: (error) => - shouldRetryCloudLink(error) && error._tag !== "CloudLinkEndpointUnavailableError", - }), - ); - if (registration.status === "superseded") { - return yield* new CloudLinkTunnelSupersededError({}); - } - if (registration.status === "recovery_required") { - yield* endpointRuntime.requestRecovery(registration.config); - } - if (registration.status !== "ready") { - return yield* new CloudLinkEndpointUnavailableError({ - reason: "origin-unconfirmed", - endpointRuntimeStatus: { status: "disabled" }, - }); - } - return { - ok: true, - endpointRuntimeStatus: registration.endpointRuntimeStatus, - } satisfies EnvironmentCloudRelayConfigResult; - } - return result; - }, - Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-relay-config")), - Effect.catchTags({ - SchemaError: internalError("persist-relay-config"), - PlatformError: internalError("register-endpoint-origin"), - }), - ); - - // Cloudflare bills per provisioned tunnel, so an environment that goes offline - // must not leave its tunnel behind. Releasing deletes only the tunnel — the - // relay keeps the link and its hostname reservation, and the next startup's - // link reconcile provisions a replacement tunnel under the same URL. - const releaseManagedTunnelOnShutdown = Effect.fn( - "environment.cloud.releaseManagedTunnelOnShutdown", - )(function* () { - // Only a managed link stores a runtime config; publish-only links have no - // tunnel to release. - const runtimeConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); - if (Option.isNone(runtimeConfig)) { - return false; - } - // Only CLI-desired managed links release eagerly because this request uses - // CLI authorization. Web/mobile links register startup recovery with their - // environment credential, and the relay reaper removes them after they are - // down for the configured grace period. Unlink still deletes either kind. - if ( - !(yield* readCliDesiredCloudLink.pipe(withSecrets)) || - (yield* readCliDesiredLinkMode.pipe(withSecrets)) !== "managed" - ) { - return false; - } - // A shutdown that hands off to a pending update is not the environment - // going offline: the service launcher or the desktop app immediately brings - // a server back (the new version, or the old one after a rollback). Deleting - // the tunnel here forces that server to provision a replacement UUID, and the - // public hostname's route to the new tunnel takes 1-2 minutes to propagate — - // the dominant cost of an update restart. Keep the tunnel instead: the next - // boot respawns the connector from the stored config and is reachable as - // soon as it connects, and the reconcile confirms the still-live tunnel - // without replacing it. - if ( - (yield* withRuntimeFiles(desktopUpdateRestartPending)) || - (yield* withRuntimeFiles(pendingUpdateHandoffExists)) - ) { - yield* Effect.logInfo("Keeping the managed tunnel across the update restart"); - return false; - } - const token = yield* cliTokenManager.getExisting; - if (Option.isNone(token)) { - return false; - } - // The link belongs to the relay it was installed against, so target the - // persisted URL: T3CODE_RELAY_URL may have changed since the link was made. - const relayUrl = yield* secrets.get(RELAY_URL_SECRET); - if (Option.isNone(relayUrl)) { - return false; - } - const environmentId = yield* environment.getEnvironmentId; - // Stop the local connector before the relay deletes the tunnel it serves. - yield* endpointRuntime.applyConfig(null); - const response = yield* HttpClientRequest.delete( - `${bytesToString(relayUrl.value)}/v1/client/environment-links/${encodeURIComponent(environmentId)}/tunnel`, - ).pipe( - HttpClientRequest.bearerToken(token.value.accessToken), - httpClient.execute, - Effect.flatMap(filterRelayResponse), - Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), - withRelayClientTracing, - ); - // ok:false means the relay skipped deletion because a concurrent provision - // owns the recorded tunnel now — leave the stored config alone. - if (!response.ok) { - return false; - } - // The connector token died with the tunnel. Drop the stored config so the - // next start waits for the link reconcile instead of respawning the relay - // client with a dead token. Kept when the release request fails: the tunnel - // still exists, so the stored token keeps working across the restart. - // Only dropped while it is still the config this shutdown released — a fast - // restart may already have reconciled and stored a fresh config for its - // replacement tunnel, and that one must survive this finalizer. - const storedConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); - if ( - Option.isSome(storedConfig) && - bytesToString(storedConfig.value) === bytesToString(runtimeConfig.value) - ) { - yield* secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); - yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); - } - return true; - }); - - const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* () { - const [ - cloudUserId, - relayUrl, - relayIssuer, - endpointRuntimeConfig, - publishAgentActivity, - holdWebhooks, - ] = yield* Effect.all( - [ - secrets.get(CLOUD_LINKED_USER_ID), - secrets.get(RELAY_URL_SECRET), - secrets.get(RELAY_ISSUER_SECRET), - secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), - secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), - secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET), - ], - { concurrency: 6 }, - ); - return { - linked: Option.isSome(cloudUserId), - cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, - relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, - relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null, - // The managed tunnel runtime config is only stored for managed links; a - // publish-only link leaves it absent. - managedTunnelActive: Option.isSome(endpointRuntimeConfig), - publishAgentActivity: Option.isSome(publishAgentActivity) - ? bytesToString(publishAgentActivity.value) === "true" - : false, - holdWebhooksWhileOffline: - Option.isSome(holdWebhooks) && bytesToString(holdWebhooks.value) === "true", - } satisfies EnvironmentCloudLinkStateResult; - }); - - const linkState = Effect.fn("environment.cloud.linkState")( - function* () { - return yield* readCloudLinkState(); - }, - Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-relay-config")), - ); - - const unlink = Effect.fn("environment.cloud.unlink")( - function* () { - return yield* endpointRuntime.withLinkStateLock( - Effect.gen(function* () { - const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(null); - yield* Effect.all( - [ - secrets.remove(CLOUD_LINKED_USER_ID), - secrets.remove(RELAY_URL_SECRET), - secrets.remove(RELAY_ISSUER_SECRET), - secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), - secrets.remove(CLOUD_MINT_PUBLIC_KEY), - secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), - secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), - secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), - secrets.remove(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET), - ], - { concurrency: 9 }, - ); - yield* setCliDesiredCloudLink(false).pipe(withSecrets); - return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; - }), - ); - }, - Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("remove-relay-config")), - ); - - const pushHoldWebhooksWhileOffline = Effect.fn("CloudPreferences.pushHoldWebhooksWhileOffline")( - function* (holdWebhooksWhileOffline: boolean) { - const connection = yield* readRelayConnection.pipe(withSecrets); - if (connection === null) { - return yield* new CloudLinkNotLinkedError({}); - } - const environmentId = yield* environment.getEnvironmentId; - const client = yield* makeRelayEnvironmentClient(connection); - yield* client.server - .updateLinkPreferences({ - params: { environmentId }, - payload: { holdWebhooksWhileOffline }, - }) - .pipe(Effect.timeout("10 seconds"), Effect.catch(internalError("update-webhook-settings"))); - }, - ); - - const savePreference = (name: string, value: boolean) => - secrets - .set(name, stringToBytes(String(value))) - .pipe(Effect.catch(internalError("persist-preferences"))); - - // One update at a time, so two requests can't each leave one setting behind. - const preferencesLock = yield* Semaphore.make(1); - - const savePreferences = Effect.fn("CloudPreferences.update")( - function* (input: EnvironmentCloudPreferencesRequest) { - // All or nothing: the activity setting is saved first, before the relay - // is told anything, and put back if the hold change then fails. Both - // current values are read up front; a failed read stops here, before - // anything changes, because a guessed value would be the rollback target. - const readCurrent = (name: string) => - secrets.get(name).pipe(Effect.catch(internalError("read-preferences"))); - const previousActivity = yield* readCurrent(PUBLISH_AGENT_ACTIVITY_SECRET); - const previousHold = yield* readCurrent(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET); - yield* savePreference(PUBLISH_AGENT_ACTIVITY_SECRET, input.publishAgentActivity); - if (input.holdWebhooksWhileOffline !== undefined) { - const next = input.holdWebhooksWhileOffline; - const previous = Option.match(previousHold, { - onNone: () => false, - onSome: (bytes) => bytesToString(bytes) === "true", - }); - yield* pushHoldWebhooksWhileOffline(next).pipe( - Effect.andThen( - savePreference(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, next).pipe( - Effect.tapError(() => - previous === next - ? Effect.void - : pushHoldWebhooksWhileOffline(previous).pipe(Effect.catch(rollbackFailed)), - ), - ), - ), - Effect.tapError(() => - Option.match(previousActivity, { - onNone: () => secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), - onSome: (bytes) => secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, bytes), - }).pipe(Effect.catch(rollbackFailed)), - ), - ); - } - yield* awarenessRelay.requestCatchUp(); - }, - // A client that disconnects mid-update must not stop it between a save and - // its rollback, so an update always finishes or undoes itself. - Effect.uninterruptible, - preferencesLock.withPermits(1), - ); - - const updatePreferences = Effect.fn("environment.cloud.preferences")( - function* (input: EnvironmentCloudPreferencesRequest) { - yield* savePreferences(input); - return yield* readCloudLinkState(); - }, - Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-preferences")), - ); - - const answerHealthRequest = Effect.fn("environment.cloud.health")( - function* (request: RelayCloudEnvironmentHealthRequest) { - const cloudMintPublicKey = yield* secrets - .get(CLOUD_MINT_PUBLIC_KEY) - .pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), - ), - ); - const relayIssuer = yield* secrets - .get(RELAY_ISSUER_SECRET) - .pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : secrets - .get(RELAY_URL_SECRET) - .pipe( - Effect.flatMap((fallbackBytes) => - Option.isSome(fallbackBytes) - ? Effect.succeed(bytesToString(fallbackBytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}), - ), - ), - ), - ), - ); - const environmentId = yield* environment.getEnvironmentId; - const linkedCloudUserId = yield* readInstalledCloudUserId; - const now = yield* DateTime.now; - const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); - const proofOption = yield* verifyRelayJwt({ - publicKey: cloudMintPublicKey, - token: request.proof, - typ: RELAY_HEALTH_REQUEST_TYP, - issuer: normalizeRelayIssuer(relayIssuer), - audience: `t3-env:${environmentId}`, - nowEpochSeconds: nowSeconds, - }).pipe(Effect.flatMap(decodeCloudHealthProof), Effect.option); - if ( - Option.isNone(proofOption) || - proofOption.value.environmentId !== environmentId || - proofOption.value.sub !== linkedCloudUserId || - !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || - !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" }) - ) { - return yield* new CloudLinkProofRejectedError({ request: "health" }); - } - const proof = proofOption.value; - - const jtiSecretName = `${CLOUD_HEALTH_JTI_PREFIX}${proof.jti}`; - const nonceSecretName = `${CLOUD_HEALTH_NONCE_PREFIX}${proof.nonce}`; - const consumedReplayGuards = yield* consumeCloudReplayGuards({ - secrets, - names: [jtiSecretName, nonceSecretName], - value: stringToBytes(DateTime.formatIso(now)), - }); - if (!consumedReplayGuards) { - return yield* new CloudLinkProofReplayedError({ request: "health" }); - } - - const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); - const descriptor = yield* environment.getDescriptor; - const responseExpiresAt = DateTime.add(now, { minutes: 5 }); - const responsePayload = { - iss: `t3-env:${environmentId}`, - aud: normalizeRelayIssuer(relayIssuer), - sub: environmentId, - jti: yield* crypto.randomUUIDv4, - iat: nowSeconds, - exp: Math.floor(responseExpiresAt.epochMilliseconds / 1_000), - environmentId, - requestNonce: proof.nonce, - status: "online", - descriptor, - checkedAt: DateTime.formatIso(now), - } satisfies RelayEnvironmentHealthResponseProofPayload; - const responseProof = yield* signRelayJwt({ - privateKey: keyPair.privateKey, - typ: RELAY_HEALTH_RESPONSE_TYP, - payload: responsePayload, - }).pipe( - Effect.mapError( - (cause) => - new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ - cause, - }), - ), - ); - return { - environmentId, - status: "online", - descriptor, - checkedAt: responsePayload.checkedAt, - proof: responseProof, - } satisfies RelayEnvironmentHealthResponse; - }, - Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("answer-health")), - Effect.catchTags({ PlatformError: internalError("answer-health") }), - ); - - const mintCredential = Effect.fn("environment.cloud.mintCredential")( - function* (request: RelayCloudMintCredentialRequest) { - const cloudMintPublicKey = yield* secrets - .get(CLOUD_MINT_PUBLIC_KEY) - .pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), - ), - ); - const relayIssuer = yield* secrets - .get(RELAY_ISSUER_SECRET) - .pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : secrets - .get(RELAY_URL_SECRET) - .pipe( - Effect.flatMap((fallbackBytes) => - Option.isSome(fallbackBytes) - ? Effect.succeed(bytesToString(fallbackBytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}), - ), - ), - ), - ), - ); - const environmentId = yield* environment.getEnvironmentId; - const linkedCloudUserId = yield* readInstalledCloudUserId; - const now = yield* DateTime.now; - const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); - const proofOption = yield* verifyRelayJwt({ - publicKey: cloudMintPublicKey, - token: request.proof, - typ: RELAY_MINT_REQUEST_TYP, - issuer: normalizeRelayIssuer(relayIssuer), - audience: `t3-env:${environmentId}`, - nowEpochSeconds: nowSeconds, - }).pipe(Effect.flatMap(decodeCloudMintProof), Effect.option); - if ( - Option.isNone(proofOption) || - proofOption.value.environmentId !== environmentId || - proofOption.value.sub !== linkedCloudUserId || - proofOption.value.cnf.jkt !== proofOption.value.clientProofKeyThumbprint || - !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || - !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" }) - ) { - return yield* new CloudLinkProofRejectedError({ request: "mint" }); - } - const proof = proofOption.value; - - const jtiSecretName = `${CLOUD_MINT_JTI_PREFIX}${proof.jti}`; - const nonceSecretName = `${CLOUD_MINT_NONCE_PREFIX}${proof.nonce}`; - const consumedReplayGuards = yield* consumeCloudReplayGuards({ - secrets, - names: [jtiSecretName, nonceSecretName], - value: stringToBytes(DateTime.formatIso(now)), - }); - if (!consumedReplayGuards) { - return yield* new CloudLinkProofReplayedError({ request: "mint" }); - } - - const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); - const issued = yield* environmentAuth.createPairingLink({ - scopes: AuthStandardClientScopes, - subject: "cloud-connect", - ttl: Duration.minutes(2), - label: "T3 Connect connect", - proofKeyThumbprint: proof.clientProofKeyThumbprint, - }); - const responsePayload = { - iss: `t3-env:${environmentId}`, - aud: normalizeRelayIssuer(relayIssuer), - sub: environmentId, - jti: yield* crypto.randomUUIDv4, - iat: nowSeconds, - exp: Math.floor(issued.expiresAt.epochMilliseconds / 1_000), - environmentId, - clientProofKeyThumbprint: proof.clientProofKeyThumbprint, - requestNonce: proof.nonce, - credential: issued.credential, - } satisfies RelayEnvironmentMintResponseProofPayload; - const responseProof = yield* signRelayJwt({ - privateKey: keyPair.privateKey, - typ: RELAY_MINT_RESPONSE_TYP, - payload: responsePayload, - }).pipe( - Effect.mapError( - (cause) => - new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ - cause, - }), - ), - ); - return { - credential: issued.credential, - expiresAt: DateTime.formatIso(issued.expiresAt), - proof: responseProof, - } satisfies RelayEnvironmentMintResponse; - }, - Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("issue-credential")), - Effect.catchTags({ PlatformError: internalError("issue-credential") }), + const live = yield* cacheInOwnerScope( + Effect.promise(() => import("./CloudLinkLive.ts")).pipe( + Effect.flatMap((module) => module.make), + ), ); - return CloudLink.of({ - linkProof, - applyRelayConfig, - linkState, - unlink, - updatePreferences, - answerHealthRequest, - mintCredential, - reconcileDesiredLink, - reconcileDesiredLinkIfStillDesired, - registerManagedTunnelRecovery, - recoverManagedTunnel, - startManagedTunnelIfOriginConfirmed, - releaseManagedTunnelOnShutdown, + linkProof: (...args: Parameters) => + live.pipe(Effect.flatMap((value) => value.linkProof(...args))), + applyRelayConfig: (...args: Parameters) => + live.pipe(Effect.flatMap((value) => value.applyRelayConfig(...args))), + linkState: (...args: Parameters) => + live.pipe(Effect.flatMap((value) => value.linkState(...args))), + unlink: (...args: Parameters) => + live.pipe(Effect.flatMap((value) => value.unlink(...args))), + updatePreferences: (...args: Parameters) => + live.pipe(Effect.flatMap((value) => value.updatePreferences(...args))), + answerHealthRequest: (...args: Parameters) => + live.pipe(Effect.flatMap((value) => value.answerHealthRequest(...args))), + mintCredential: (...args: Parameters) => + live.pipe(Effect.flatMap((value) => value.mintCredential(...args))), + reconcileDesiredLink: (...args: Parameters) => + live.pipe(Effect.flatMap((value) => value.reconcileDesiredLink(...args))), + reconcileDesiredLinkIfStillDesired: ( + ...args: Parameters + ) => live.pipe(Effect.flatMap((value) => value.reconcileDesiredLinkIfStillDesired(...args))), + registerManagedTunnelRecovery: ( + ...args: Parameters + ) => live.pipe(Effect.flatMap((value) => value.registerManagedTunnelRecovery(...args))), + recoverManagedTunnel: (...args: Parameters) => + live.pipe(Effect.flatMap((value) => value.recoverManagedTunnel(...args))), + startManagedTunnelIfOriginConfirmed: ( + ...args: Parameters + ) => live.pipe(Effect.flatMap((value) => value.startManagedTunnelIfOriginConfirmed(...args))), + releaseManagedTunnelOnShutdown: ( + ...args: Parameters + ) => live.pipe(Effect.flatMap((value) => value.releaseManagedTunnelOnShutdown(...args))), }); }); - export const layer = Layer.effect(CloudLink, make); diff --git a/apps/server/src/cloud/CloudLinkLive.ts b/apps/server/src/cloud/CloudLinkLive.ts new file mode 100644 index 0000000000000..20b8abff2f49f --- /dev/null +++ b/apps/server/src/cloud/CloudLinkLive.ts @@ -0,0 +1,1319 @@ +/** + * The T3 Connect link lifecycle of this environment: linking it to the relay, + * applying and reading the link, unlinking, answering the relay's signed health + * and mint requests, and keeping the managed tunnel registered, recovered and + * released. HTTP handlers, server startup and shutdown all go through it. + */ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createPublicKey. +import * as NodeCrypto from "node:crypto"; +import { + AuthStandardClientScopes, + type EnvironmentCloudLinkStateResult, + type EnvironmentCloudPreferencesRequest, + type EnvironmentCloudRelayConfigResult, +} from "@t3tools/contracts"; +import { + RelayCloudEnvironmentHealthProofPayload, + type RelayCloudEnvironmentHealthRequest, + RelayCloudMintCredentialProofPayload, + type RelayCloudMintCredentialRequest, + type RelayEnvironmentConfigRequest, + type RelayEnvironmentHealthResponse, + type RelayEnvironmentHealthResponseProofPayload, + type RelayEnvironmentLinkProof, + type RelayEnvironmentLinkProofPayload, + RelayEnvironmentLinkChallengeResponse, + RelayEnvironmentLinkResponse, + type RelayEnvironmentMintResponse, + type RelayEnvironmentMintResponseProofPayload, + type RelayLinkProofRequest, + type RelayManagedEndpointOrigin, + type RelayManagedEndpointRecoveryProofPayload, + RelayManagedEndpointRecoveryRegistrationResponse, + RelayManagedEndpointRecoveryResponse, + type RelayManagedEndpointRuntimeConfig, + RelayOkResponse, +} from "@t3tools/contracts/relay"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import { + normalizeRelayIssuer, + RELAY_HEALTH_REQUEST_TYP, + RELAY_HEALTH_RESPONSE_TYP, + RELAY_LINK_PROOF_TYP, + RELAY_MANAGED_TUNNEL_RECOVERY_TYP, + RELAY_MINT_REQUEST_TYP, + RELAY_MINT_RESPONSE_TYP, + signRelayJwt, + verifyRelayJwt, +} from "@t3tools/shared/relayJwt"; +import { isSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { + HttpClient, + HttpClientRequest, + HttpClientResponse, + type HttpServerRequest, +} from "effect/http"; +import * as HttpServer from "effect/http/HttpServer"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts"; +import { makeRelayEnvironmentClient } from "../relay/relayEnvironmentClient.ts"; +import { + readCliDesiredCloudLink, + readCliDesiredLinkMode, + setCliDesiredCloudLink, +} from "./CliState.ts"; +import * as CliTokenManager from "./CliTokenManager.ts"; +import { + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_LINKED_USER_ID, + CLOUD_MINT_PUBLIC_KEY, + decodeConfirmedOrigin, + decodeRuntimeConfig, + encodeConfirmedOriginJson, + encodeEndpointRuntimeConfigJson, + HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, + PUBLISH_AGENT_ACTIVITY_SECRET, + readRelayConnection, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "./config.ts"; +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; +import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; +import { relayUrlConfig } from "./publicConfig.ts"; +import { filterRelayResponse, relayRequestError } from "./relayResponse.ts"; +import { + hasBoundedCloudProofLifetime, + hasExactScope, + hasForwardedAuthorityHeaders, + isAllowedEndpointOrigin, + isSupportedLinkProviderKind, + linkProofScopes, + managedEndpointRuntimeConfigsMatch, + parseManagedEndpointLocalOrigin, + requestAbsoluteUrl, +} from "./linkChecks.ts"; +import { desktopUpdateRestartPending, pendingUpdateHandoffExists } from "./updateHandoff.ts"; + +import { + CloudLink, + shouldRetryCloudLink, + consumeCloudReplayGuards, + CloudLinkRelayConfigInvalidError, + CloudLinkOriginInvalidError, + CloudLinkNotLinkedError, + CloudLinkAccountMismatchError, + CloudLinkAuthorizationMissingError, + CloudLinkProofRejectedError, + CloudLinkProofReplayedError, + CloudLinkTunnelSupersededError, + CloudLinkEndpointUnavailableError, + CloudLinkInternalError, +} from "./CloudLink.ts"; +const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; +const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; +const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-"; +const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-"; +const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2); + +const internalError = + (operation: CloudLinkInternalError["operation"]) => + (cause: unknown): Effect.Effect => + Effect.fail(new CloudLinkInternalError({ operation, cause })); + +const rollbackFailed = (cause: unknown) => + Effect.logWarning("Could not roll back a T3 Connect preference", { cause }); + +const requireRelayUrl = relayUrlConfig.pipe( + Effect.mapError( + (cause) => new CloudLinkInternalError({ operation: "relay-url-unconfigured", cause }), + ), +); + +function bytesToString(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +function stringToBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +function validateCloudMintPublicKey( + publicKey: string, +): Effect.Effect { + const invalid = new CloudLinkRelayConfigInvalidError({ field: "cloudMintPublicKey" }); + return Effect.try({ + try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), + catch: () => invalid, + }).pipe( + Effect.flatMap((key) => + key.asymmetricKeyType === "ed25519" ? Effect.void : Effect.fail(invalid), + ), + ); +} + +function invalidRelayConfigField( + payload: RelayEnvironmentConfigRequest, +): CloudLinkRelayConfigInvalidError["field"] | null { + if (!isSecureRelayUrl(payload.relayUrl)) return "relayUrl"; + if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) { + return "relayIssuer"; + } + if (payload.environmentCredential.trim().length === 0) return "environmentCredential"; + if (payload.cloudUserId.trim().length === 0) return "cloudUserId"; + return null; +} + +function validateRelayConfigPayload( + payload: RelayEnvironmentConfigRequest, +): Effect.Effect { + const field = invalidRelayConfigField(payload); + return field === null + ? Effect.void + : Effect.fail(new CloudLinkRelayConfigInvalidError({ field })); +} + +function normalizePemForSignedPayload(value: string): string { + return value.trim(); +} + +const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentHealthProofPayload); +const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload); + +type ManagedTunnelRecoveryProofInput = { + readonly environmentId: RelayManagedEndpointRecoveryProofPayload["environmentId"]; + readonly cloudUserId: string; + readonly relayUrl: string; +} & ( + | { + readonly action: "register"; + readonly tunnelId: string; + readonly origin: RelayManagedEndpointOrigin; + } + | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin } +); + +/** Every failure CloudLink constructs itself. */ +export const make = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const environment = yield* ServerEnvironment.ServerEnvironment; + const endpointRuntime = yield* ManagedEndpointRuntime.CloudManagedEndpointRuntime; + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const cliTokenManager = yield* CliTokenManager.CloudCliTokenManager; + const httpClient = yield* HttpClient.HttpClient; + const httpServer = yield* HttpServer.HttpServer; + const awarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + const crypto = yield* Crypto.Crypto; + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const withSecrets = Effect.provideService(ServerSecretStore.ServerSecretStore, secrets); + const withRuntimeFiles = ( + effect: Effect.Effect, + ) => + effect.pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const validateLinkedCloudUser = ( + cloudUserId: string, + ): Effect.Effect => + secrets.get(CLOUD_LINKED_USER_ID).pipe( + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ + cause, + }), + ), + Effect.flatMap((existing) => { + if (Option.isNone(existing)) { + return Effect.void; + } + const existingCloudUserId = bytesToString(existing.value); + return existingCloudUserId === cloudUserId + ? Effect.void + : Effect.fail(new CloudLinkAccountMismatchError({})); + }), + ); + + const readInstalledCloudUserId: Effect.Effect = + secrets.get(CLOUD_LINKED_USER_ID).pipe( + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ + cause, + }), + ), + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})), + ), + ); + + const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function* ( + request: RelayLinkProofRequest, + requestUrl: string, + ) { + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + if ( + !isSupportedLinkProviderKind(request) || + !isAllowedEndpointOrigin({ + origin: request.origin, + requestUrl, + }) + ) { + return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" }); + } + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 5 }); + const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); + const descriptor = yield* environment.getDescriptor; + const payload = { + iss: `t3-env:${descriptor.environmentId}`, + aud: normalizeRelayIssuer(request.relayIssuer), + sub: descriptor.environmentId, + jti: yield* crypto.randomUUIDv4, + iat: nowSeconds, + exp: Math.floor(expiresAt.epochMilliseconds / 1_000), + challenge: request.challenge, + descriptor, + environmentId: descriptor.environmentId, + environmentPublicKey: normalizePemForSignedPayload(keyPair.publicKey), + endpoint: request.endpoint, + origin: request.origin, + scopes: linkProofScopes(request), + } satisfies RelayEnvironmentLinkProofPayload; + return yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: RELAY_LINK_PROOF_TYP, + payload, + }).pipe( + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ + cause, + }), + ), + ); + }); + + const linkProof = Effect.fn("environment.cloud.linkProof")( + function* (request: RelayLinkProofRequest, httpRequest: HttpServerRequest.HttpServerRequest) { + const requestUrl = requestAbsoluteUrl(httpRequest); + if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) { + return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" }); + } + const proof = yield* makeCloudLinkProof(request, requestUrl); + return proof satisfies RelayEnvironmentLinkProof; + }, + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("generate-link-proof")), + Effect.catchTags({ PlatformError: internalError("generate-link-proof") }), + ); + + const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")( + function* (input: { + readonly config: RelayManagedEndpointRuntimeConfig; + readonly configJson: string; + readonly origin: RelayManagedEndpointOrigin; + }) { + return yield* endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const currentConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); + if ( + Option.isNone(currentConfig) || + bytesToString(currentConfig.value) !== input.configJson + ) { + return null; + } + const status = yield* endpointRuntime.applyConfig(input.config); + if (status.status !== "running") { + return yield* new CloudLinkEndpointUnavailableError({ + reason: "runtime-not-started", + endpointRuntimeStatus: status, + }); + } + const marker = yield* encodeConfirmedOriginJson({ + config: input.config, + origin: input.origin, + }); + yield* secrets.set(CLOUD_ENDPOINT_CONFIRMED_ORIGIN, stringToBytes(marker)); + return status; + }), + ); + }, + ); + + const activateManagedTunnelWithRetry = ( + input: { + readonly config: RelayManagedEndpointRuntimeConfig; + readonly configJson: string; + readonly origin: RelayManagedEndpointOrigin; + }, + retryRuntimeFailures: boolean, + ) => { + const activate = activateManagedTunnel(input); + return retryRuntimeFailures + ? activate.pipe( + Effect.retry({ + while: (error) => + error._tag === "CloudLinkEndpointUnavailableError" && + ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus( + error.endpointRuntimeStatus, + ), + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.jittered, + ), + }), + ) + : activate; + }; + + const startManagedTunnelIfOriginConfirmed = Effect.fn( + "environment.cloud.startManagedCloudTunnelIfOriginConfirmed", + )(function* (localOrigin: string, options?: { readonly requireConfirmedOrigin?: boolean }) { + const requireConfirmedOrigin = options?.requireConfirmedOrigin ?? true; + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), + catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), + }); + return yield* endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const [runtimeBytes, markerBytes] = yield* Effect.all([ + secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), + secrets.get(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), + ]); + if (Option.isNone(runtimeBytes)) return false; + const config = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeBytes.value))); + if (config === null || config.providerKind !== "cloudflare_tunnel") return false; + // With the marker required, only a config the relay already confirmed on + // this port may start. Without it, startup is falling back after the + // relay stayed unreachable: an unconfirmed origin may send traffic to a + // stale port, but that beats no remote access at all. + if (requireConfirmedOrigin) { + if (Option.isNone(markerBytes)) return false; + const marker = Option.getOrNull(decodeConfirmedOrigin(bytesToString(markerBytes.value))); + if ( + marker === null || + !managedEndpointRuntimeConfigsMatch(marker.config, config) || + marker.origin.localHttpHost !== parsedOrigin.origin.localHttpHost || + marker.origin.localHttpPort !== parsedOrigin.origin.localHttpPort + ) { + return false; + } + } + const status = yield* endpointRuntime.applyConfig(config); + if (status.status !== "running") { + return yield* new CloudLinkEndpointUnavailableError({ + reason: "runtime-not-started", + endpointRuntimeStatus: status, + }); + } + return true; + }), + ); + }); + + const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( + payload: RelayEnvironmentConfigRequest, + options?: { + readonly lockHeld?: boolean; + readonly confirmedOrigin?: RelayManagedEndpointOrigin; + }, + ) { + const apply = Effect.gen(function* () { + yield* validateRelayConfigPayload(payload); + yield* validateLinkedCloudUser(payload.cloudUserId); + yield* validateCloudMintPublicKey(payload.cloudMintPublicKey); + // Reject unsupported runtimes before touching the connector so a bad + // payload cannot stop a healthy tunnel on its way to a 503. + if ( + payload.endpointRuntime !== null && + payload.endpointRuntime.providerKind !== "cloudflare_tunnel" + ) { + return yield* new CloudLinkEndpointUnavailableError({ + reason: "runtime-not-started", + endpointRuntimeStatus: { + status: "unsupported", + providerKind: payload.endpointRuntime.providerKind, + }, + }); + } + yield* endpointRuntime.applyConfig(null); + yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); + + yield* secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl)); + yield* secrets.set( + RELAY_ISSUER_SECRET, + stringToBytes(payload.relayIssuer ?? payload.relayUrl), + ); + yield* secrets.set(CLOUD_LINKED_USER_ID, stringToBytes(payload.cloudUserId)); + yield* secrets.set( + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + stringToBytes(payload.environmentCredential), + ); + yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey)); + yield* awarenessRelay.requestCatchUp(); + if (payload.endpointRuntime) { + const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); + yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(endpointRuntimeJson)); + } else { + yield* secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); + } + if (payload.endpointRuntime === null || options?.confirmedOrigin === undefined) { + return { + ok: true, + endpointRuntimeStatus: { status: "disabled" }, + } satisfies EnvironmentCloudRelayConfigResult; + } + const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(payload.endpointRuntime); + if (endpointRuntimeStatus.status !== "running") { + return yield* new CloudLinkEndpointUnavailableError({ + reason: "runtime-not-started", + endpointRuntimeStatus, + }); + } + const marker = yield* encodeConfirmedOriginJson({ + config: payload.endpointRuntime, + origin: options.confirmedOrigin, + }); + yield* secrets.set(CLOUD_ENDPOINT_CONFIRMED_ORIGIN, stringToBytes(marker)); + return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; + }); + return yield* options?.lockHeld ? apply : endpointRuntime.withLinkStateLock(apply); + }); + + const relayClientRequest = (input: { + readonly url: string; + readonly token: string; + readonly payload: unknown; + readonly schema: Schema.Decoder; + readonly timeout?: Duration.Input; + }) => + HttpClientRequest.post(input.url).pipe( + HttpClientRequest.bearerToken(input.token), + HttpClientRequest.bodyJson(input.payload), + Effect.flatMap(httpClient.execute), + Effect.flatMap(filterRelayResponse), + Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), + Effect.timeout(input.timeout ?? "10 seconds"), + Effect.mapError(relayRequestError), + withRelayClientTracing, + ); + + const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")( + function* (localOrigin: string) { + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), + catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), + }); + const token = yield* cliTokenManager.getExisting.pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new CloudLinkAuthorizationMissingError({})), + onSome: Effect.succeed, + }), + ), + ); + const mode = yield* readCliDesiredLinkMode.pipe(withSecrets); + const managedTunnelsEnabled = mode !== "publish_only"; + const relayUrl = yield* requireRelayUrl; + const challenge = yield* relayClientRequest({ + url: `${relayUrl}/v1/client/environment-link-challenges`, + token: token.accessToken, + payload: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled, + }, + schema: RelayEnvironmentLinkChallengeResponse, + }); + const proof = yield* makeCloudLinkProof( + { + challenge: challenge.challenge, + relayIssuer: relayUrl, + endpoint: { + httpBaseUrl: parsedOrigin.httpBaseUrl, + wsBaseUrl: parsedOrigin.wsBaseUrl, + providerKind: managedTunnelsEnabled ? "cloudflare_tunnel" : "manual", + }, + origin: parsedOrigin.origin, + }, + parsedOrigin.httpBaseUrl, + ); + const link = yield* relayClientRequest({ + url: `${relayUrl}/v1/client/environment-links`, + token: token.accessToken, + payload: { + proof, + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled, + }, + schema: RelayEnvironmentLinkResponse, + timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, + }); + yield* setCliDesiredCloudLink(true, mode).pipe(withSecrets); + yield* applyCloudRelayConfig( + { + relayUrl, + relayIssuer: link.relayIssuer, + cloudUserId: link.cloudUserId, + environmentCredential: link.environmentCredential, + cloudMintPublicKey: link.cloudMintPublicKey, + endpointRuntime: link.endpointRuntime, + }, + { + lockHeld: true, + confirmedOrigin: parsedOrigin.origin, + }, + ); + // Callers decide on managed tunnel recovery from the mode this link + // actually used, not from a value read before the relay round trip. + return mode; + }, + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-desired-link")), + ); + + const reconcileDesiredLink = Effect.fn("environment.cloud.reconcileDesiredLink")(function* ( + localOrigin: string, + ) { + return yield* endpointRuntime.withLinkStateLock(reconcileDesiredCloudLinkWith(localOrigin)); + }); + + const reconcileDesiredLinkIfStillDesired = Effect.fn( + "environment.cloud.reconcileDesiredLinkIfStillDesired", + )(function* (localOrigin: string) { + return yield* endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + if (!(yield* readCliDesiredCloudLink.pipe(withSecrets))) { + return null; + } + return yield* reconcileDesiredCloudLinkWith(localOrigin); + }), + ); + }); + + const makeManagedTunnelRecoveryProof = Effect.fn( + "environment.cloud.makeManagedTunnelRecoveryProof", + )(function* (input: ManagedTunnelRecoveryProofInput) { + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + const configuredIssuer = yield* secrets.get(RELAY_ISSUER_SECRET); + const now = yield* DateTime.now; + const issuedAt = Math.floor(now.epochMilliseconds / 1_000); + const claims = { + iss: `t3-env:${input.environmentId}`, + aud: normalizeRelayIssuer( + Option.isSome(configuredIssuer) ? bytesToString(configuredIssuer.value) : input.relayUrl, + ), + sub: input.environmentId, + jti: yield* crypto.randomUUIDv4, + iat: issuedAt, + exp: issuedAt + 60, + environmentId: input.environmentId, + cloudUserId: input.cloudUserId, + }; + const payload = + input.action === "register" + ? { + ...claims, + action: "register" as const, + tunnelId: input.tunnelId, + origin: input.origin, + } + : { ...claims, action: "recover" as const, origin: input.origin }; + + return yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: RELAY_MANAGED_TUNNEL_RECOVERY_TYP, + payload, + }).pipe( + Effect.mapError( + (cause) => new CloudLinkInternalError({ operation: "sign-recovery-proof", cause }), + ), + ); + }); + + const registerManagedTunnelRecovery = Effect.fn( + "environment.cloud.registerManagedCloudTunnelRecovery", + )(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) { + const [runtimeConfig, relayUrl, cloudUserId, environmentCredential] = yield* Effect.all([ + secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), + secrets.get(RELAY_URL_SECRET), + secrets.get(CLOUD_LINKED_USER_ID), + secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + if ( + Option.isNone(runtimeConfig) || + Option.isNone(relayUrl) || + Option.isNone(cloudUserId) || + Option.isNone(environmentCredential) + ) { + return { status: "not_linked" as const }; + } + + const config = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeConfig.value))); + if (config?.providerKind !== "cloudflare_tunnel") { + return { status: "not_linked" as const }; + } + + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), + catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), + }); + if (config.tunnelId === undefined) { + return { status: "recovery_required" as const, config }; + } + const origin = parsedOrigin.origin; + const environmentId = yield* environment.getEnvironmentId; + const relayUrlValue = bytesToString(relayUrl.value); + const cloudUserIdValue = bytesToString(cloudUserId.value); + const proof = yield* makeManagedTunnelRecoveryProof({ + action: "register", + environmentId, + cloudUserId: cloudUserIdValue, + relayUrl: relayUrlValue, + tunnelId: config.tunnelId, + origin, + }); + const registered = yield* relayClientRequest({ + url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`, + token: bytesToString(environmentCredential.value), + payload: { + cloudUserId: cloudUserIdValue, + tunnelId: config.tunnelId, + origin, + proof, + }, + schema: RelayManagedEndpointRecoveryRegistrationResponse, + }); + if (registered.status === "recovery_required") { + return { status: registered.status, config }; + } + const endpointRuntimeStatus = yield* activateManagedTunnelWithRetry( + { + config, + configJson: bytesToString(runtimeConfig.value), + origin, + }, + options?.retryRuntimeFailures === true, + ); + return endpointRuntimeStatus === null + ? { status: "superseded" as const } + : { status: "ready" as const, endpointRuntimeStatus }; + }); + + const recoverManagedTunnel = Effect.fn("environment.cloud.recoverManagedCloudTunnel")(function* ( + localOrigin: string, + expectedConfig?: RelayManagedEndpointRuntimeConfig, + options?: { readonly retryRuntimeFailures?: boolean }, + ) { + const [runtimeConfig, relayUrl, cloudUserId, environmentCredential] = yield* Effect.all([ + secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), + secrets.get(RELAY_URL_SECRET), + secrets.get(CLOUD_LINKED_USER_ID), + secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + if ( + Option.isNone(runtimeConfig) || + Option.isNone(relayUrl) || + Option.isNone(cloudUserId) || + Option.isNone(environmentCredential) + ) { + return false; + } + if (expectedConfig !== undefined) { + const current = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeConfig.value))); + if ( + current === null || + current.providerKind !== expectedConfig.providerKind || + current.connectorToken !== expectedConfig.connectorToken || + current.tunnelId !== expectedConfig.tunnelId || + current.tunnelName !== expectedConfig.tunnelName + ) { + return false; + } + } + + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), + catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), + }); + + const environmentId = yield* environment.getEnvironmentId; + const relayUrlValue = bytesToString(relayUrl.value); + const cloudUserIdValue = bytesToString(cloudUserId.value); + const origin = parsedOrigin.origin; + const proof = yield* makeManagedTunnelRecoveryProof({ + action: "recover", + environmentId, + cloudUserId: cloudUserIdValue, + relayUrl: relayUrlValue, + origin, + }); + const recovered = yield* relayClientRequest({ + url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`, + token: bytesToString(environmentCredential.value), + payload: { + cloudUserId: cloudUserIdValue, + origin, + proof, + }, + schema: RelayManagedEndpointRecoveryResponse, + timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, + }); + if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") { + return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" }); + } + + const encoded = yield* encodeEndpointRuntimeConfigJson(recovered.endpointRuntime).pipe( + Effect.mapError( + (cause) => new CloudLinkInternalError({ operation: "persist-recovered-tunnel", cause }), + ), + ); + const stored = yield* endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const currentConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); + if ( + Option.isNone(currentConfig) || + bytesToString(currentConfig.value) !== bytesToString(runtimeConfig.value) + ) { + return false; + } + yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded)); + yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); + return true; + }), + ); + if (!stored) return false; + const status = yield* activateManagedTunnelWithRetry( + { + config: recovered.endpointRuntime, + configJson: encoded, + origin, + }, + options?.retryRuntimeFailures === true, + ); + return status !== null; + }); + + const applyRelayConfig = Effect.fn("environment.cloud.relayConfig")( + function* (payload: RelayEnvironmentConfigRequest) { + const result = yield* applyCloudRelayConfig(payload); + if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") { + const address = httpServer.address; + if (typeof address === "string" || !("port" in address)) { + return yield* new CloudLinkInternalError({ operation: "resolve-server-origin" }); + } + const registration = yield* registerManagedTunnelRecovery( + `http://127.0.0.1:${address.port}`, + ).pipe( + Effect.retry({ + times: 2, + while: (error) => + shouldRetryCloudLink(error) && error._tag !== "CloudLinkEndpointUnavailableError", + }), + ); + if (registration.status === "superseded") { + return yield* new CloudLinkTunnelSupersededError({}); + } + if (registration.status === "recovery_required") { + yield* endpointRuntime.requestRecovery(registration.config); + } + if (registration.status !== "ready") { + return yield* new CloudLinkEndpointUnavailableError({ + reason: "origin-unconfirmed", + endpointRuntimeStatus: { status: "disabled" }, + }); + } + return { + ok: true, + endpointRuntimeStatus: registration.endpointRuntimeStatus, + } satisfies EnvironmentCloudRelayConfigResult; + } + return result; + }, + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-relay-config")), + Effect.catchTags({ + SchemaError: internalError("persist-relay-config"), + PlatformError: internalError("register-endpoint-origin"), + }), + ); + + // Cloudflare bills per provisioned tunnel, so an environment that goes offline + // must not leave its tunnel behind. Releasing deletes only the tunnel — the + // relay keeps the link and its hostname reservation, and the next startup's + // link reconcile provisions a replacement tunnel under the same URL. + const releaseManagedTunnelOnShutdown = Effect.fn( + "environment.cloud.releaseManagedTunnelOnShutdown", + )(function* () { + // Only a managed link stores a runtime config; publish-only links have no + // tunnel to release. + const runtimeConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); + if (Option.isNone(runtimeConfig)) { + return false; + } + // Only CLI-desired managed links release eagerly because this request uses + // CLI authorization. Web/mobile links register startup recovery with their + // environment credential, and the relay reaper removes them after they are + // down for the configured grace period. Unlink still deletes either kind. + if ( + !(yield* readCliDesiredCloudLink.pipe(withSecrets)) || + (yield* readCliDesiredLinkMode.pipe(withSecrets)) !== "managed" + ) { + return false; + } + // A shutdown that hands off to a pending update is not the environment + // going offline: the service launcher or the desktop app immediately brings + // a server back (the new version, or the old one after a rollback). Deleting + // the tunnel here forces that server to provision a replacement UUID, and the + // public hostname's route to the new tunnel takes 1-2 minutes to propagate — + // the dominant cost of an update restart. Keep the tunnel instead: the next + // boot respawns the connector from the stored config and is reachable as + // soon as it connects, and the reconcile confirms the still-live tunnel + // without replacing it. + if ( + (yield* withRuntimeFiles(desktopUpdateRestartPending)) || + (yield* withRuntimeFiles(pendingUpdateHandoffExists)) + ) { + yield* Effect.logInfo("Keeping the managed tunnel across the update restart"); + return false; + } + const token = yield* cliTokenManager.getExisting; + if (Option.isNone(token)) { + return false; + } + // The link belongs to the relay it was installed against, so target the + // persisted URL: T3CODE_RELAY_URL may have changed since the link was made. + const relayUrl = yield* secrets.get(RELAY_URL_SECRET); + if (Option.isNone(relayUrl)) { + return false; + } + const environmentId = yield* environment.getEnvironmentId; + // Stop the local connector before the relay deletes the tunnel it serves. + yield* endpointRuntime.applyConfig(null); + const response = yield* HttpClientRequest.delete( + `${bytesToString(relayUrl.value)}/v1/client/environment-links/${encodeURIComponent(environmentId)}/tunnel`, + ).pipe( + HttpClientRequest.bearerToken(token.value.accessToken), + httpClient.execute, + Effect.flatMap(filterRelayResponse), + Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), + withRelayClientTracing, + ); + // ok:false means the relay skipped deletion because a concurrent provision + // owns the recorded tunnel now — leave the stored config alone. + if (!response.ok) { + return false; + } + // The connector token died with the tunnel. Drop the stored config so the + // next start waits for the link reconcile instead of respawning the relay + // client with a dead token. Kept when the release request fails: the tunnel + // still exists, so the stored token keeps working across the restart. + // Only dropped while it is still the config this shutdown released — a fast + // restart may already have reconciled and stored a fresh config for its + // replacement tunnel, and that one must survive this finalizer. + const storedConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); + if ( + Option.isSome(storedConfig) && + bytesToString(storedConfig.value) === bytesToString(runtimeConfig.value) + ) { + yield* secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); + yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); + } + return true; + }); + + const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* () { + const [ + cloudUserId, + relayUrl, + relayIssuer, + endpointRuntimeConfig, + publishAgentActivity, + holdWebhooks, + ] = yield* Effect.all( + [ + secrets.get(CLOUD_LINKED_USER_ID), + secrets.get(RELAY_URL_SECRET), + secrets.get(RELAY_ISSUER_SECRET), + secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), + secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), + secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET), + ], + { concurrency: 6 }, + ); + return { + linked: Option.isSome(cloudUserId), + cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, + relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, + relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null, + // The managed tunnel runtime config is only stored for managed links; a + // publish-only link leaves it absent. + managedTunnelActive: Option.isSome(endpointRuntimeConfig), + publishAgentActivity: Option.isSome(publishAgentActivity) + ? bytesToString(publishAgentActivity.value) === "true" + : false, + holdWebhooksWhileOffline: + Option.isSome(holdWebhooks) && bytesToString(holdWebhooks.value) === "true", + } satisfies EnvironmentCloudLinkStateResult; + }); + + const linkState = Effect.fn("environment.cloud.linkState")( + function* () { + return yield* readCloudLinkState(); + }, + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-relay-config")), + ); + + const unlink = Effect.fn("environment.cloud.unlink")( + function* () { + return yield* endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(null); + yield* Effect.all( + [ + secrets.remove(CLOUD_LINKED_USER_ID), + secrets.remove(RELAY_URL_SECRET), + secrets.remove(RELAY_ISSUER_SECRET), + secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + secrets.remove(CLOUD_MINT_PUBLIC_KEY), + secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), + secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), + secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), + secrets.remove(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET), + ], + { concurrency: 9 }, + ); + yield* setCliDesiredCloudLink(false).pipe(withSecrets); + return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; + }), + ); + }, + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("remove-relay-config")), + ); + + const pushHoldWebhooksWhileOffline = Effect.fn("CloudPreferences.pushHoldWebhooksWhileOffline")( + function* (holdWebhooksWhileOffline: boolean) { + const connection = yield* readRelayConnection.pipe(withSecrets); + if (connection === null) { + return yield* new CloudLinkNotLinkedError({}); + } + const environmentId = yield* environment.getEnvironmentId; + const client = yield* makeRelayEnvironmentClient(connection); + yield* client.server + .updateLinkPreferences({ + params: { environmentId }, + payload: { holdWebhooksWhileOffline }, + }) + .pipe(Effect.timeout("10 seconds"), Effect.catch(internalError("update-webhook-settings"))); + }, + ); + + const savePreference = (name: string, value: boolean) => + secrets + .set(name, stringToBytes(String(value))) + .pipe(Effect.catch(internalError("persist-preferences"))); + + // One update at a time, so two requests can't each leave one setting behind. + const preferencesLock = yield* Semaphore.make(1); + + const savePreferences = Effect.fn("CloudPreferences.update")( + function* (input: EnvironmentCloudPreferencesRequest) { + // All or nothing: the activity setting is saved first, before the relay + // is told anything, and put back if the hold change then fails. Both + // current values are read up front; a failed read stops here, before + // anything changes, because a guessed value would be the rollback target. + const readCurrent = (name: string) => + secrets.get(name).pipe(Effect.catch(internalError("read-preferences"))); + const previousActivity = yield* readCurrent(PUBLISH_AGENT_ACTIVITY_SECRET); + const previousHold = yield* readCurrent(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET); + yield* savePreference(PUBLISH_AGENT_ACTIVITY_SECRET, input.publishAgentActivity); + if (input.holdWebhooksWhileOffline !== undefined) { + const next = input.holdWebhooksWhileOffline; + const previous = Option.match(previousHold, { + onNone: () => false, + onSome: (bytes) => bytesToString(bytes) === "true", + }); + yield* pushHoldWebhooksWhileOffline(next).pipe( + Effect.andThen( + savePreference(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, next).pipe( + Effect.tapError(() => + previous === next + ? Effect.void + : pushHoldWebhooksWhileOffline(previous).pipe(Effect.catch(rollbackFailed)), + ), + ), + ), + Effect.tapError(() => + Option.match(previousActivity, { + onNone: () => secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), + onSome: (bytes) => secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, bytes), + }).pipe(Effect.catch(rollbackFailed)), + ), + ); + } + yield* awarenessRelay.requestCatchUp(); + }, + // A client that disconnects mid-update must not stop it between a save and + // its rollback, so an update always finishes or undoes itself. + Effect.uninterruptible, + preferencesLock.withPermits(1), + ); + + const updatePreferences = Effect.fn("environment.cloud.preferences")( + function* (input: EnvironmentCloudPreferencesRequest) { + yield* savePreferences(input); + return yield* readCloudLinkState(); + }, + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-preferences")), + ); + + const answerHealthRequest = Effect.fn("environment.cloud.health")( + function* (request: RelayCloudEnvironmentHealthRequest) { + const cloudMintPublicKey = yield* secrets + .get(CLOUD_MINT_PUBLIC_KEY) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), + ), + ); + const relayIssuer = yield* secrets + .get(RELAY_ISSUER_SECRET) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : secrets + .get(RELAY_URL_SECRET) + .pipe( + Effect.flatMap((fallbackBytes) => + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) + : Effect.fail( + new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}), + ), + ), + ), + ), + ); + const environmentId = yield* environment.getEnvironmentId; + const linkedCloudUserId = yield* readInstalledCloudUserId; + const now = yield* DateTime.now; + const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); + const proofOption = yield* verifyRelayJwt({ + publicKey: cloudMintPublicKey, + token: request.proof, + typ: RELAY_HEALTH_REQUEST_TYP, + issuer: normalizeRelayIssuer(relayIssuer), + audience: `t3-env:${environmentId}`, + nowEpochSeconds: nowSeconds, + }).pipe(Effect.flatMap(decodeCloudHealthProof), Effect.option); + if ( + Option.isNone(proofOption) || + proofOption.value.environmentId !== environmentId || + proofOption.value.sub !== linkedCloudUserId || + !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || + !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" }) + ) { + return yield* new CloudLinkProofRejectedError({ request: "health" }); + } + const proof = proofOption.value; + + const jtiSecretName = `${CLOUD_HEALTH_JTI_PREFIX}${proof.jti}`; + const nonceSecretName = `${CLOUD_HEALTH_NONCE_PREFIX}${proof.nonce}`; + const consumedReplayGuards = yield* consumeCloudReplayGuards({ + secrets, + names: [jtiSecretName, nonceSecretName], + value: stringToBytes(DateTime.formatIso(now)), + }); + if (!consumedReplayGuards) { + return yield* new CloudLinkProofReplayedError({ request: "health" }); + } + + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + const descriptor = yield* environment.getDescriptor; + const responseExpiresAt = DateTime.add(now, { minutes: 5 }); + const responsePayload = { + iss: `t3-env:${environmentId}`, + aud: normalizeRelayIssuer(relayIssuer), + sub: environmentId, + jti: yield* crypto.randomUUIDv4, + iat: nowSeconds, + exp: Math.floor(responseExpiresAt.epochMilliseconds / 1_000), + environmentId, + requestNonce: proof.nonce, + status: "online", + descriptor, + checkedAt: DateTime.formatIso(now), + } satisfies RelayEnvironmentHealthResponseProofPayload; + const responseProof = yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: RELAY_HEALTH_RESPONSE_TYP, + payload: responsePayload, + }).pipe( + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ + cause, + }), + ), + ); + return { + environmentId, + status: "online", + descriptor, + checkedAt: responsePayload.checkedAt, + proof: responseProof, + } satisfies RelayEnvironmentHealthResponse; + }, + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("answer-health")), + Effect.catchTags({ PlatformError: internalError("answer-health") }), + ); + + const mintCredential = Effect.fn("environment.cloud.mintCredential")( + function* (request: RelayCloudMintCredentialRequest) { + const cloudMintPublicKey = yield* secrets + .get(CLOUD_MINT_PUBLIC_KEY) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), + ), + ); + const relayIssuer = yield* secrets + .get(RELAY_ISSUER_SECRET) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : secrets + .get(RELAY_URL_SECRET) + .pipe( + Effect.flatMap((fallbackBytes) => + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) + : Effect.fail( + new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}), + ), + ), + ), + ), + ); + const environmentId = yield* environment.getEnvironmentId; + const linkedCloudUserId = yield* readInstalledCloudUserId; + const now = yield* DateTime.now; + const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); + const proofOption = yield* verifyRelayJwt({ + publicKey: cloudMintPublicKey, + token: request.proof, + typ: RELAY_MINT_REQUEST_TYP, + issuer: normalizeRelayIssuer(relayIssuer), + audience: `t3-env:${environmentId}`, + nowEpochSeconds: nowSeconds, + }).pipe(Effect.flatMap(decodeCloudMintProof), Effect.option); + if ( + Option.isNone(proofOption) || + proofOption.value.environmentId !== environmentId || + proofOption.value.sub !== linkedCloudUserId || + proofOption.value.cnf.jkt !== proofOption.value.clientProofKeyThumbprint || + !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || + !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" }) + ) { + return yield* new CloudLinkProofRejectedError({ request: "mint" }); + } + const proof = proofOption.value; + + const jtiSecretName = `${CLOUD_MINT_JTI_PREFIX}${proof.jti}`; + const nonceSecretName = `${CLOUD_MINT_NONCE_PREFIX}${proof.nonce}`; + const consumedReplayGuards = yield* consumeCloudReplayGuards({ + secrets, + names: [jtiSecretName, nonceSecretName], + value: stringToBytes(DateTime.formatIso(now)), + }); + if (!consumedReplayGuards) { + return yield* new CloudLinkProofReplayedError({ request: "mint" }); + } + + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + const issued = yield* environmentAuth.createPairingLink({ + scopes: AuthStandardClientScopes, + subject: "cloud-connect", + ttl: Duration.minutes(2), + label: "T3 Connect connect", + proofKeyThumbprint: proof.clientProofKeyThumbprint, + }); + const responsePayload = { + iss: `t3-env:${environmentId}`, + aud: normalizeRelayIssuer(relayIssuer), + sub: environmentId, + jti: yield* crypto.randomUUIDv4, + iat: nowSeconds, + exp: Math.floor(issued.expiresAt.epochMilliseconds / 1_000), + environmentId, + clientProofKeyThumbprint: proof.clientProofKeyThumbprint, + requestNonce: proof.nonce, + credential: issued.credential, + } satisfies RelayEnvironmentMintResponseProofPayload; + const responseProof = yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: RELAY_MINT_RESPONSE_TYP, + payload: responsePayload, + }).pipe( + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ + cause, + }), + ), + ); + return { + credential: issued.credential, + expiresAt: DateTime.formatIso(issued.expiresAt), + proof: responseProof, + } satisfies RelayEnvironmentMintResponse; + }, + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("issue-credential")), + Effect.catchTags({ PlatformError: internalError("issue-credential") }), + ); + + return CloudLink.of({ + linkProof, + applyRelayConfig, + linkState, + unlink, + updatePreferences, + answerHealthRequest, + mintCredential, + reconcileDesiredLink, + reconcileDesiredLinkIfStillDesired, + registerManagedTunnelRecovery, + recoverManagedTunnel, + startManagedTunnelIfOriginConfirmed, + releaseManagedTunnelOnShutdown, + }); +}); diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts index ee8f6d521324d..355ae02422503 100644 --- a/apps/server/src/mcpApps/McpAppRequests.ts +++ b/apps/server/src/mcpApps/McpAppRequests.ts @@ -1,38 +1,18 @@ -import { +import type { + McpAppCallToolInput, + McpAppCallToolResult, + McpAppToolInfoInput, + McpAppToolInfo, + McpAppReadResourceInput, + McpAppReadResourceResult, + McpAppUpdateModelContextInput, McpAppRequestError, - type McpAppCallToolInput, - type McpAppCallToolResult, - type McpAppReadResourceInput, - type McpAppReadResourceResult, - type McpAppToolInfo, - type McpAppToolInfoInput, - type McpAppUpdateModelContextInput, - type OrchestrationV2AppThread, - type ThreadId, - type TurnItemId, } from "@t3tools/contracts"; -import { mcpAppToolCallableByApp, type McpAppReference } from "@t3tools/shared/mcpApp"; -import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Predicate from "effect/Predicate"; -import * as Schema from "effect/Schema"; +import { cacheInOwnerScope } from "@t3tools/shared/lazyService"; -import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; -import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; -import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; -import * as McpAppModelContext from "./McpAppModelContext.ts"; - -/** - * Requests an MCP App makes of its own MCP server. The app is resolved from the - * tool call that produced it, so it reaches only that server, and each request - * runs through the thread's live provider session, whose MCP client owns the - * connection and its credentials. A stopped session is reported, never - * started: an app view must not spend a provider turn. - */ export class McpAppRequests extends Context.Service< McpAppRequests, { @@ -51,187 +31,18 @@ export class McpAppRequests extends Context.Service< } >()("t3/mcpApps/McpAppRequests") {} -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const utf8 = new TextEncoder(); - -const readOnlyHint = (tool: ProviderAdapter.ProviderAdapterV2McpTool) => - Predicate.isObject(tool.annotations) && tool.annotations.readOnlyHint === true; - -const toolTitle = (tool: ProviderAdapter.ProviderAdapterV2McpTool) => - Predicate.isObject(tool.annotations) && typeof tool.annotations.title === "string" - ? tool.annotations.title - : undefined; - const make = Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadManagement = yield* ThreadManagementService.ThreadManagementService; - const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; - const modelContext = yield* McpAppModelContext.McpAppModelContext; - - const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) => - new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) }); - - /** The app a tool call produced, read from the stored item. */ - const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: { - readonly threadId: ThreadId; - readonly itemId: TurnItemId; - }) { - // The stored item, not the wire projection, so the reference is intact. - const item = yield* orchestrator - .getTurnItem({ threadId: input.threadId, itemId: input.itemId }) - .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); - const app: McpAppReference | undefined = - item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; - if (item === null || app === undefined || item.providerThreadId === null) { - return yield* fail(input.threadId, "not-an-app"); - } - return { app, providerThreadId: item.providerThreadId }; - }); - - /** The app, its provider thread, and the live session's MCP Apps operations. */ - const resolve = Effect.fn("McpAppRequests.resolve")(function* (input: { - readonly threadId: ThreadId; - readonly itemId: TurnItemId; - }) { - const { app, providerThreadId } = yield* resolveApp(input); - const projection = yield* threadManagement - .getThreadRecords(input.threadId, ["providerThreads"]) - .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); - const providerThread = projection.providerThreads.find( - (candidate) => candidate.id === providerThreadId, - ); - if (providerThread?.providerSessionId == null) { - return yield* fail(input.threadId, "session-stopped"); - } - const runtime = Option.getOrUndefined( - yield* providerSessions - .get(providerThread.providerSessionId) - .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))), - ); - if (runtime === undefined) return yield* fail(input.threadId, "session-stopped"); - const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps | undefined = runtime.mcpApps; - if (mcpApps === undefined) return yield* fail(input.threadId, "provider-unsupported"); - return { app, providerThread, mcpApps }; - }); - - const findTool = Effect.fn("McpAppRequests.findTool")(function* ( - threadId: ThreadId, - resolved: Effect.Success>, - name: string, - ) { - const tools = yield* resolved.mcpApps - .listTools({ providerThread: resolved.providerThread, server: resolved.app.server }) - .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); - return tools.find((tool) => tool.name === name); - }); - - const toolInfo = Effect.fn("McpAppRequests.toolInfo")(function* (input: McpAppToolInfoInput) { - const resolved = yield* resolve(input); - const tool = yield* findTool(input.threadId, resolved, input.name); - const title = tool === undefined ? undefined : toolTitle(tool); - return { - callable: tool !== undefined && mcpAppToolCallableByApp(tool._meta), - readOnly: tool !== undefined && readOnlyHint(tool), - ...(title === undefined ? {} : { title }), - ...(tool === undefined ? {} : { tool }), - } satisfies McpAppToolInfo; - }); - - const callTool = Effect.fn("McpAppRequests.callTool")(function* (input: McpAppCallToolInput) { - const resolved = yield* resolve(input); - // The spec forbids apps calling tools hidden from them; checked here too, - // since a client's own check is only a courtesy to its user. - const tool = yield* findTool(input.threadId, resolved, input.name); - if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) { - return yield* fail(input.threadId, "tool-not-callable"); - } - return yield* resolved.mcpApps - .callTool({ - providerThread: resolved.providerThread, - server: resolved.app.server, - tool: input.name, - arguments: input.arguments, - }) - .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + const service = yield* cacheInOwnerScope( + Effect.promise(() => import("./McpAppRequestsLive.ts")).pipe( + Effect.flatMap((live) => live.make), + ), + ); + return McpAppRequests.of({ + callTool: (input) => service.pipe(Effect.flatMap((value) => value.callTool(input))), + toolInfo: (input) => service.pipe(Effect.flatMap((value) => value.toolInfo(input))), + readResource: (input) => service.pipe(Effect.flatMap((value) => value.readResource(input))), + updateModelContext: (input) => + service.pipe(Effect.flatMap((value) => value.updateModelContext(input))), }); - - const readResource = Effect.fn("McpAppRequests.readResource")(function* ( - input: McpAppReadResourceInput, - ) { - const resolved = yield* resolve(input); - return yield* resolved.mcpApps - .readResource({ - providerThread: resolved.providerThread, - server: resolved.app.server, - uri: input.uri, - }) - .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); - }); - - // Context is state for the agent's next turn, so it needs the app but not a - // live session. Text blocks are kept as written and structured content as - // JSON; other content kinds are not declared by the host. - /** Whether `threadId` is `ancestorId` or forked from it, however many forks deep. */ - const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* ( - threadId: ThreadId, - ancestorId: ThreadId, - ) { - // Walks fork links until the chain ends; a thread seen twice means a - // corrupt cycle, which proves nothing. - const seen = new Set(); - let current: ThreadId | null = threadId; - while (current !== null && !seen.has(current)) { - if (current === ancestorId) return true; - seen.add(current); - const records: { readonly thread: OrchestrationV2AppThread } = yield* threadManagement - .getThreadRecords(current, []) - .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); - const { lineage } = records.thread; - current = lineage.relationshipToParent === "fork" ? lineage.parentThreadId : null; - } - return false; - }); - - const updateModelContext = Effect.fn("McpAppRequests.updateModelContext")(function* ( - input: McpAppUpdateModelContextInput, - ) { - const { app } = yield* resolveApp(input); - // Context belongs to the conversation on screen, which only shows this app - // when it is the app's own thread or one forked from it. - if (!(yield* descendsFrom(input.conversationThreadId, input.threadId))) { - return yield* fail(input.threadId, "not-an-app"); - } - const texts: Array = []; - for (const block of input.content ?? []) { - if (!Predicate.isObject(block) || block.type !== "text" || typeof block.text !== "string") { - return yield* fail(input.threadId, "unsupported-content"); - } - texts.push(block.text); - } - if (input.structuredContent !== undefined) { - texts.push(encodeJson(input.structuredContent)); - } - // Kept as sent; blank text clears the app's context in the store. - const text = texts.join("\n"); - if (utf8.encode(text).byteLength > McpAppModelContext.MCP_APP_MODEL_CONTEXT_MAX_BYTES) { - return yield* fail( - input.threadId, - "request-failed", - new Error("Model context is too large."), - ); - } - yield* modelContext - .set({ - threadId: input.conversationThreadId, - itemId: input.itemId, - server: app.server, - tool: app.tool, - text, - }) - .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); - }); - - return McpAppRequests.of({ callTool, toolInfo, readResource, updateModelContext }); }); - export const layer = Layer.effect(McpAppRequests, make); diff --git a/apps/server/src/mcpApps/McpAppRequestsLive.ts b/apps/server/src/mcpApps/McpAppRequestsLive.ts new file mode 100644 index 0000000000000..f601d29a3021f --- /dev/null +++ b/apps/server/src/mcpApps/McpAppRequestsLive.ts @@ -0,0 +1,215 @@ +import { McpAppRequests } from "./McpAppRequests.ts"; +import { + McpAppRequestError, + type McpAppCallToolInput, + type McpAppReadResourceInput, + type McpAppToolInfo, + type McpAppToolInfoInput, + type McpAppUpdateModelContextInput, + type OrchestrationV2AppThread, + type ThreadId, + type TurnItemId, +} from "@t3tools/contracts"; +import { mcpAppToolCallableByApp, type McpAppReference } from "@t3tools/shared/mcpApp"; +import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as McpAppModelContext from "./McpAppModelContext.ts"; + +/** + * Requests an MCP App makes of its own MCP server. The app is resolved from the + * tool call that produced it, so it reaches only that server, and each request + * runs through the thread's live provider session, whose MCP client owns the + * connection and its credentials. A stopped session is reported, never + * started: an app view must not spend a provider turn. + */ + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const utf8 = new TextEncoder(); + +const readOnlyHint = (tool: ProviderAdapter.ProviderAdapterV2McpTool) => + Predicate.isObject(tool.annotations) && tool.annotations.readOnlyHint === true; + +const toolTitle = (tool: ProviderAdapter.ProviderAdapterV2McpTool) => + Predicate.isObject(tool.annotations) && typeof tool.annotations.title === "string" + ? tool.annotations.title + : undefined; + +export const make = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const modelContext = yield* McpAppModelContext.McpAppModelContext; + + const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) => + new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) }); + + /** The app a tool call produced, read from the stored item. */ + const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) { + // The stored item, not the wire projection, so the reference is intact. + const item = yield* orchestrator + .getTurnItem({ threadId: input.threadId, itemId: input.itemId }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + const app: McpAppReference | undefined = + item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; + if (item === null || app === undefined || item.providerThreadId === null) { + return yield* fail(input.threadId, "not-an-app"); + } + return { app, providerThreadId: item.providerThreadId }; + }); + + /** The app, its provider thread, and the live session's MCP Apps operations. */ + const resolve = Effect.fn("McpAppRequests.resolve")(function* (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) { + const { app, providerThreadId } = yield* resolveApp(input); + const projection = yield* threadManagement + .getThreadRecords(input.threadId, ["providerThreads"]) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === providerThreadId, + ); + if (providerThread?.providerSessionId == null) { + return yield* fail(input.threadId, "session-stopped"); + } + const runtime = Option.getOrUndefined( + yield* providerSessions + .get(providerThread.providerSessionId) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))), + ); + if (runtime === undefined) return yield* fail(input.threadId, "session-stopped"); + const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps | undefined = runtime.mcpApps; + if (mcpApps === undefined) return yield* fail(input.threadId, "provider-unsupported"); + return { app, providerThread, mcpApps }; + }); + + const findTool = Effect.fn("McpAppRequests.findTool")(function* ( + threadId: ThreadId, + resolved: Effect.Success>, + name: string, + ) { + const tools = yield* resolved.mcpApps + .listTools({ providerThread: resolved.providerThread, server: resolved.app.server }) + .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); + return tools.find((tool) => tool.name === name); + }); + + const toolInfo = Effect.fn("McpAppRequests.toolInfo")(function* (input: McpAppToolInfoInput) { + const resolved = yield* resolve(input); + const tool = yield* findTool(input.threadId, resolved, input.name); + const title = tool === undefined ? undefined : toolTitle(tool); + return { + callable: tool !== undefined && mcpAppToolCallableByApp(tool._meta), + readOnly: tool !== undefined && readOnlyHint(tool), + ...(title === undefined ? {} : { title }), + ...(tool === undefined ? {} : { tool }), + } satisfies McpAppToolInfo; + }); + + const callTool = Effect.fn("McpAppRequests.callTool")(function* (input: McpAppCallToolInput) { + const resolved = yield* resolve(input); + // The spec forbids apps calling tools hidden from them; checked here too, + // since a client's own check is only a courtesy to its user. + const tool = yield* findTool(input.threadId, resolved, input.name); + if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) { + return yield* fail(input.threadId, "tool-not-callable"); + } + return yield* resolved.mcpApps + .callTool({ + providerThread: resolved.providerThread, + server: resolved.app.server, + tool: input.name, + arguments: input.arguments, + }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + }); + + const readResource = Effect.fn("McpAppRequests.readResource")(function* ( + input: McpAppReadResourceInput, + ) { + const resolved = yield* resolve(input); + return yield* resolved.mcpApps + .readResource({ + providerThread: resolved.providerThread, + server: resolved.app.server, + uri: input.uri, + }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + }); + + // Context is state for the agent's next turn, so it needs the app but not a + // live session. Text blocks are kept as written and structured content as + // JSON; other content kinds are not declared by the host. + /** Whether `threadId` is `ancestorId` or forked from it, however many forks deep. */ + const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* ( + threadId: ThreadId, + ancestorId: ThreadId, + ) { + // Walks fork links until the chain ends; a thread seen twice means a + // corrupt cycle, which proves nothing. + const seen = new Set(); + let current: ThreadId | null = threadId; + while (current !== null && !seen.has(current)) { + if (current === ancestorId) return true; + seen.add(current); + const records: { readonly thread: OrchestrationV2AppThread } = yield* threadManagement + .getThreadRecords(current, []) + .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); + const { lineage } = records.thread; + current = lineage.relationshipToParent === "fork" ? lineage.parentThreadId : null; + } + return false; + }); + + const updateModelContext = Effect.fn("McpAppRequests.updateModelContext")(function* ( + input: McpAppUpdateModelContextInput, + ) { + const { app } = yield* resolveApp(input); + // Context belongs to the conversation on screen, which only shows this app + // when it is the app's own thread or one forked from it. + if (!(yield* descendsFrom(input.conversationThreadId, input.threadId))) { + return yield* fail(input.threadId, "not-an-app"); + } + const texts: Array = []; + for (const block of input.content ?? []) { + if (!Predicate.isObject(block) || block.type !== "text" || typeof block.text !== "string") { + return yield* fail(input.threadId, "unsupported-content"); + } + texts.push(block.text); + } + if (input.structuredContent !== undefined) { + texts.push(encodeJson(input.structuredContent)); + } + // Kept as sent; blank text clears the app's context in the store. + const text = texts.join("\n"); + if (utf8.encode(text).byteLength > McpAppModelContext.MCP_APP_MODEL_CONTEXT_MAX_BYTES) { + return yield* fail( + input.threadId, + "request-failed", + new Error("Model context is too large."), + ); + } + yield* modelContext + .set({ + threadId: input.conversationThreadId, + itemId: input.itemId, + server: app.server, + tool: app.tool, + text, + }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + }); + + return McpAppRequests.of({ callTool, toolInfo, readResource, updateModelContext }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 6777f0a2902a9..5c43e064f1135 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1,3 +1,4 @@ +import * as ClaudeAgentSdkQueryRunnerService from "../../provider/ClaudeAgentSdkQueryRunner.ts"; import * as NodeOS from "node:os"; import type { @@ -1105,7 +1106,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio prefix: "t3-claude-binary-path-", }), ), - Effect.provideService(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, { + Effect.provideService(ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner, { allocateSessionId: Effect.succeed("native-thread-claude-binary-path"), open: (input) => Effect.sync(() => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index 730ed8a575f2c..cd638bf858ea6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -1,3 +1,4 @@ +import * as ClaudeAgentSdkQueryRunnerService from "../../provider/ClaudeAgentSdkQueryRunner.ts"; import { forkSession, query, @@ -922,13 +923,13 @@ const makeClaudeAgentSdkReplayQueryRunner = Effect.fn("ClaudeAgentSdkReplayQuery function replayQueryRunnerService( transcript: ClaudeAgentSdkReplayTranscript, queryRunner: ClaudeQueryRunner, -): ClaudeAdapterV2.ClaudeAgentSdkQueryRunner["Service"] { +): ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner["Service"] { const replay = (run: () => A) => Effect.try({ try: run, catch: (cause) => replayQueryRunnerError(transcript, cause), }); - return ClaudeAdapterV2.ClaudeAgentSdkQueryRunner.of({ + return ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner.of({ allocateSessionId: Effect.succeed(nativeSessionIdFor(transcript)), open: (input) => replay(() => queryRunner.open(input)), forkSession: (input) => replay(() => queryRunner.forkSession(input)), @@ -940,9 +941,9 @@ function replayQueryRunnerService( function layerClaudeAgentSdkReplayQueryRunner( transcript: ClaudeAgentSdkReplayTranscript, options: { readonly replayGate?: ProviderReplayGate } = {}, -): Layer.Layer { +): Layer.Layer { return Layer.effect( - ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, + ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner, makeClaudeAgentSdkReplayQueryRunner(transcript, options), ); } @@ -954,16 +955,16 @@ function layerClaudeAgentSdkReplay( // Shared across runtimes; its owner asserts completion. readonly queryRunner?: ClaudeQueryRunner; } = {}, -): Layer.Layer { +): Layer.Layer { if (options.queryRunner !== undefined) { return Layer.succeed( - ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, + ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner, replayQueryRunnerService(transcript, options.queryRunner), ); } const queryRunner = makeReplayQueryRunner(transcript, options); return Layer.effect( - ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, + ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner, Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1451ec9ff62e6..3484b729864f9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,3 +1,4 @@ +import { ClaudeAgentSdkQueryRunner } from "../../provider/ClaudeAgentSdkQueryRunner.ts"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; import { dynamicToolTitle, @@ -10,7 +11,6 @@ import { type CanUseTool, forkSession as forkClaudeSession, type ForkSessionOptions, - type ForkSessionResult, getSubagentMessages, query, type Options as ClaudeQueryOptions, @@ -70,7 +70,6 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -365,26 +364,6 @@ export class ClaudeBackgroundWorkBlocksQueryReplacementError extends Schema.Tagg } } -export interface ClaudeAgentSdkQueryRunnerShape { - readonly allocateSessionId: Effect.Effect; - readonly open: ( - input: ClaudeAgentSdkQueryOpenInput, - ) => Effect.Effect; - readonly forkSession: ( - input: ClaudeAgentSdkSessionForkInput, - ) => Effect.Effect; - /** The tool call that launched a subagent, read from the CLI's session storage. */ - readonly subagentLaunchToolUseId: ( - input: ClaudeAgentSdkSubagentLookupInput, - ) => Effect.Effect; - readonly assertComplete: Effect.Effect; -} - -export class ClaudeAgentSdkQueryRunner extends Context.Service< - ClaudeAgentSdkQueryRunner, - ClaudeAgentSdkQueryRunnerShape ->()("t3/orchestration-v2/Adapters/ClaudeAdapterV2/ClaudeAgentSdkQueryRunner") {} - export interface ClaudeAgentSdkSessionForkInput { readonly sessionId: string; readonly options: ForkSessionOptions; @@ -3044,7 +3023,7 @@ export interface ClaudeAdapterV2Options { readonly path: Path.Path; readonly crypto: Crypto.Crypto; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; + readonly queryRunner: ClaudeAgentSdkQueryRunner["Service"]; readonly scopedLimitNames?: Ref.Ref; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; /** Sink for wake-turn continuation requests; defaults to dropping them. */ diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 67d3354917944..2ae7ee45e1572 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1,3 +1,4 @@ +import * as CodexAppServerClientFactoryService from "../../provider/CodexAppServerClientFactory.ts"; import * as NodeOS from "node:os"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; @@ -800,7 +801,7 @@ describe("CodexAdapterV2 process spawning", () => { PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn" }), ); }); - const factory = yield* CodexAdapterV2.CodexAppServerClientFactory.pipe( + const factory = yield* CodexAppServerClientFactoryService.CodexAppServerClientFactory.pipe( Effect.provide(CodexAdapterV2.layerAppServerClientFactory), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService( @@ -1982,37 +1983,38 @@ describe("CodexAdapterV2 post-settle continuation", () => { const serverConfig = yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie); const continuationRequests: Array = []; - const clientFactory: CodexAdapterV2.CodexAppServerClientFactoryShape = { - open: (openInput) => - Layer.build(CodexReplay.layerReplay(transcript)).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - providerSessionId: openInput.providerSessionId, - cause, - }), - ), - Effect.flatMap((context) => - Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.map((client) => - withCodexReplayChildMetadata(client, transcript, readChildMetadata), - ), - Effect.map( - (client) => - ({ - ...client, - request: (method, params) => - onRequest(method, params).pipe( - Effect.andThen(client.request(method, params)), - ), - }) satisfies CodexClient.CodexAppServerClient["Service"], + const clientFactory: CodexAppServerClientFactoryService.CodexAppServerClientFactory["Service"] = + { + open: (openInput) => + Layer.build(CodexReplay.layerReplay(transcript)).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + providerSessionId: openInput.providerSessionId, + cause, + }), + ), + Effect.flatMap((context) => + Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.map((client) => + withCodexReplayChildMetadata(client, transcript, readChildMetadata), + ), + Effect.map( + (client) => + ({ + ...client, + request: (method, params) => + onRequest(method, params).pipe( + Effect.andThen(client.request(method, params)), + ), + }) satisfies CodexClient.CodexAppServerClient["Service"], + ), + Effect.provide(context), ), - Effect.provide(context), ), ), - ), - }; + }; const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({ instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, settings: DEFAULT_CODEX_SETTINGS, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts index ea949828076da..c934b886f24f0 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts @@ -1,3 +1,4 @@ +import * as CodexAppServerClientFactoryService from "../../provider/CodexAppServerClientFactory.ts"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -189,25 +190,28 @@ export function layer(input: { input.driver === undefined ? CodexReplay.layerReplay(input.transcript) : CodexReplay.layerReplayWithDriver(input.driver); - const layerReplayClientFactory = Layer.succeed(CodexAdapterV2.CodexAppServerClientFactory, { - open: (openInput) => - Effect.gen(function* () { - const context = yield* Layer.build(layerReplay).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - providerSessionId: openInput.providerSessionId, - cause, - }), - ), - ); - return yield* Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.map((client) => withCodexReplayChildMetadata(client, input.transcript)), - Effect.provide(context), - ); - }), - }); + const layerReplayClientFactory = Layer.succeed( + CodexAppServerClientFactoryService.CodexAppServerClientFactory, + { + open: (openInput) => + Effect.gen(function* () { + const context = yield* Layer.build(layerReplay).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + providerSessionId: openInput.providerSessionId, + cause, + }), + ), + ); + return yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.map((client) => withCodexReplayChildMetadata(client, input.transcript)), + Effect.provide(context), + ); + }), + }, + ); const layerServerConfig = Layer.effect( ServerConfig.ServerConfig, makeReplayServerConfig(input.transcript.scenario).pipe(Effect.orDie), diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 398337b22e2c1..2148a2fd40daf 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,3 +1,4 @@ +import { CodexAppServerClientFactory } from "../../provider/CodexAppServerClientFactory.ts"; import { revertCodexThread } from "../../provider/CodexThreadRevert.ts"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; @@ -67,7 +68,6 @@ import * as CodexClient from "effect-codex-app-server/client"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexSchema from "effect-codex-app-server/schema"; import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -1270,26 +1270,6 @@ type CodexSubAgentActivityItem = Extract< { readonly type: "subAgentActivity" } >; -export interface CodexAppServerClientFactoryShape { - readonly open: (input: { - readonly instanceId: ProviderInstanceId; - readonly threadId: ThreadId; - readonly providerSessionId: OrchestrationV2ProviderSession["id"]; - readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; - readonly settings: CodexSettings; - readonly environment: NodeJS.ProcessEnv; - }) => Effect.Effect< - CodexClient.CodexAppServerClient["Service"], - ProviderAdapter.ProviderAdapterOpenSessionError, - Scope.Scope - >; -} - -export class CodexAppServerClientFactory extends Context.Service< - CodexAppServerClientFactory, - CodexAppServerClientFactoryShape ->()("t3/orchestration-v2/Adapters/CodexAdapterV2/CodexAppServerClientFactory") {} - /** * Config overrides sent with every `thread/start`, `thread/resume` and `thread/fork`. * Codex 0.152 made the `update_plan` checklist tool opt-in; T3 renders it as the @@ -1656,7 +1636,7 @@ export interface CodexAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly settings: CodexSettings; readonly environment: NodeJS.ProcessEnv; - readonly clientFactory: CodexAppServerClientFactoryShape; + readonly clientFactory: CodexAppServerClientFactory["Service"]; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; /** * Resolves launch settings when each session opens, replacing `settings` and diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index 424bae4f25d9d..6316c66ee00e6 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -419,7 +419,7 @@ function layerOpenCodeReplayRuntime(transcript: OpenCodeSdkReplayTranscript) { detail: "OpenCode replay does not execute commands.", }), ), - createOpenCodeSdkClient: () => client, + createOpenCodeSdkClient: () => Effect.succeed(client), loadOpenCodeInventory: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 948258e81b9aa..925ca4d90e6c5 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,6 +1,5 @@ import { projectTurnItemForWire } from "./WireProjection.ts"; import * as Stream from "effect/Stream"; -import { makeThreadFind, findProjectedThreadItems } from "./ThreadFind.ts"; import type { OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, @@ -4935,9 +4934,20 @@ export const layer: Layer.Layer = ), ); - const { searchThread, searchThreadStream } = yield* makeThreadFind((threadId) => - readTimelineIndex(threadId, new Set()).pipe(Effect.map((index) => index.visible)), + const threadFind = yield* Effect.cached( + Effect.promise(() => import("./ThreadFind.ts")).pipe( + Effect.flatMap(({ makeThreadFind }) => + makeThreadFind((threadId) => + readTimelineIndex(threadId, new Set()).pipe(Effect.map((index) => index.visible)), + ), + ), + Effect.provideService(SqlClient.SqlClient, sql), + ), ); + const searchThread: ProjectionStoreV2Shape["searchThread"] = (input) => + threadFind.pipe(Effect.flatMap((find) => find.searchThread(input))); + const searchThreadStream: ProjectionStoreV2Shape["searchThreadStream"] = (input) => + Stream.unwrap(threadFind.pipe(Effect.map((find) => find.searchThreadStream(input)))); const getThreadHistoryPage: ProjectionStoreV2Shape["getThreadHistoryPage"] = ( threadId, @@ -6554,13 +6564,15 @@ export const layerMemory: Layer.Layer = Layer.effect( Effect.flatMap(({ projection, snapshotSequence }) => projection.thread.deletedAt !== null ? Effect.fail(new ProjectionStoreThreadNotFoundError({ threadId: input.threadId })) - : Effect.succeed( - // This store has no project records, so only worktree paths resolve labels. - findProjectedThreadItems( - projection.visibleTurnItems, - input, - snapshotSequence, - projection.thread.worktreePath ?? undefined, + : Effect.promise(() => import("./ThreadFind.ts")).pipe( + Effect.map(({ findProjectedThreadItems }) => + // This store has no project records, so only worktree paths resolve labels. + findProjectedThreadItems( + projection.visibleTurnItems, + input, + snapshotSequence, + projection.thread.worktreePath ?? undefined, + ), ), ), ), diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 072a022f90c08..40cbcd29824a0 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1,131 +1,21 @@ -// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off - Playwright callbacks run outside the Effect runtime. -// Screencasts ignore emulated device scale; real 2x keeps captures sharp. -// --disable-gpu uses cheaper software compositing while preserving SwiftShader WebGL. import { - BUILT_IN_BROWSER_PROFILES, - DEFAULT_BROWSER_PROFILE_ID, - FILL_PREVIEW_VIEWPORT, - findBrowserProfile, - INCOGNITO_BROWSER_PROFILE_ID, - resolveBrowserProfiles, - type BrowserProfile, - type PreviewReportProfilesInput, PREVIEW_AUTOMATION_SERVER_OPERATIONS, - PreviewViewportSetting as PreviewViewportSettingSchema, - PROVIDER_SEND_TURN_MAX_FILE_BYTES, - type PreviewAutomationActionEvent, - type PreviewAutomationClickInput, - type PreviewAutomationDialogInput, - type PreviewAutomationConsoleEntry, - type PreviewAutomationDragInput, - type PreviewAutomationEvaluateInput, - type PreviewAutomationHoverInput, - type PreviewAutomationNavigateInput, - type PreviewAutomationNetworkEntry, - type PreviewAutomationOpenInput, - type PreviewAutomationPressInput, - type PreviewAutomationRequest, - type PreviewAutomationResizeInput, - type PreviewAutomationScrollInput, - type PreviewAutomationSelectInput, - type PreviewAutomationSetColorSchemeInput, - type PreviewAutomationStatus, - type PreviewAutomationTypeInput, - type PreviewAutomationUploadInput, - type PreviewAutomationWaitForInput, - PreviewClearProfileError, - type PreviewEvent, - type PreviewNavStatus, - type PreviewSessionSnapshot, - type PreviewViewportSetting, - ThreadId, SERVER_BROWSER_AUTOMATION_CLIENT_ID, - type PreviewAppearancePreference, + type PreviewReportProfilesInput, + type PreviewClearProfileError, } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import { normalizePreviewUrl } from "@t3tools/shared/preview"; -import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; -import * as NodeCrypto from "node:crypto"; -import * as NodeFSP from "node:fs/promises"; -import * as NodePath from "node:path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import { constVoid } from "effect/Function"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; -import * as Exit from "effect/Exit"; -import * as Scope from "effect/Scope"; +import type * as Scope from "effect/Scope"; +import type * as Queue from "effect/Queue"; +import type * as Option from "effect/Option"; import * as Stream from "effect/Stream"; -import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import type { - BrowserContext, - CDPSession, - Dialog, - Download, - FileChooser, - Page, -} from "playwright-core"; - -import { PENDING_ATTACHMENT_THREAD_SEGMENT } from "../attachmentStore.ts"; -import * as ServerConfig from "../config.ts"; -import { resolveRootCliCommand } from "../cli/invocation.ts"; -import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts"; -import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as PreviewManager from "./Manager.ts"; -import * as ServerBrowserPage from "./ServerBrowserPage.ts"; -import * as PreviewBrowser from "./PreviewBrowser.ts"; -import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; -import { presentAsChrome, ServerBrowserContexts } from "./ServerBrowserContexts.ts"; -import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; - -const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID; -const RENDER_SCALE = 2; -// Chromium allows three unacked frames, so 100 ms pacing caps viewers near 30 fps. -const SCREENCAST_ACK_PACE_MS = 100; -const SCREENCAST_SETTLE_MS = 200; -// Only scrolling triggers reduced-quality motion frames; animations stay sharp. -const SCREENCAST_MOTION_FRAMES = 4; -const SCREENCAST_MOTION_WINDOW_MS = 300; -const SCREENCAST_MOTION_QUALITY = 50; -const HOST_RECONNECT_DELAY = "1 second"; -/** How long a new tab waits for the desktop app to mount it before running headless. */ -const DESKTOP_ATTACH_TIMEOUT = "10 seconds"; -const VIEWER_OUTPUT_LIMIT = 64; -const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; -const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); -/** The agent cursor glides to its target, then pulses just before the press, like desktop tabs. */ -const AGENT_CURSOR_MOVE_MS = 160; -const AGENT_CURSOR_CLICK_LEAD_MS = 40; - -const sleepUntil = (deadline: number) => - new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); - -// Touch viewers raise the keyboard for editable targets, including opaque frames. -const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { - let element = document.elementFromPoint(x, y); - while (element && element.shadowRoot) { - const inner = element.shadowRoot.elementFromPoint(x, y); - if (!inner || inner === element) break; - element = inner; - } - if (element && element.tagName === "LABEL" && element.control) element = element.control; - if (!element) return false; - if (element.tagName === "IFRAME" || element.tagName === "FRAME") return true; - if (element.isContentEditable) return true; - if (element.tagName === "TEXTAREA") return !element.disabled && !element.readOnly; - if (element.tagName !== "INPUT") return false; - const nonText = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]; - return !nonText.includes(element.type) && !element.disabled && !element.readOnly; -}`; -const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const; -const NAVIGATION_TIMEOUT_MS = 15_000; -const VIEWER_NAVIGATION_OPTIONS = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const; -const ACTION_TIMELINE_LIMIT = 50; - +import { cacheInOwnerScope } from "@t3tools/shared/lazyService"; export class ServerBrowserTabNotFoundError extends Schema.TaggedError()( "ServerBrowserTabNotFoundError", { threadId: Schema.String, tabId: Schema.String }, @@ -135,14 +25,6 @@ export class ServerBrowserTabNotFoundError extends Schema.TaggedError()( "ServerBrowserLaunchError", { cause: Schema.Defect() }, @@ -253,2159 +135,31 @@ export class ServerBrowser extends Context.Service< } >()("t3/preview/ServerBrowser") {} -interface ViewerState { - readonly id: string; - readonly canOperate: boolean; - readonly pressedKeys: Map; - readonly pressedButtons: Map<"left" | "middle" | "right", { x: number; y: number }>; - readonly push: (output: ServerBrowserViewerOutput) => void; - readonly pause: () => Promise; - readonly resume: () => Promise; - scrolledAt: number; - /** Last input from this viewer; page copies reach its clipboard only right after. */ - inputAt: number; - /** Panel bounds, retained in fixed mode; passive viewers never request a size. */ - requestedSize: { width: number; height: number; order: number } | null; -} - -interface EncoderWindow { - __t3Recorder: { - cursor(x: number, y: number, click: boolean): void; - frame(data: string, width: number): Promise; - stop(): Promise<{ mimeType: string | null; count: number; bytes: number }>; - chunk(index: number): Promise; - }; -} - -interface Recording { - readonly encoder: Page; - readonly session: CDPSession; - readonly startedAt: string; - /** Frames still being handed to the encoder; stopping waits for them. */ - readonly framesInFlight: Set>; -} - -interface ServerTab { - readonly threadId: ThreadId; - readonly tabId: string; - readonly page: Page; - readonly cdp: CDPSession; - readonly createdAt: number; - /** The agent's latest request on this tab; idle agent tabs close. */ - usedAt: number; - readonly viewers: Set; - readonly consoleEntries: Array; - readonly networkEntries: Array; - readonly actionTimeline: Array; - readonly control: SessionControl; - /** The tab's own storage context, closed with it. Popups share their opener's. */ - readonly isolatedContext: boolean; - /** - * Set when the desktop app renders this tab. The server drives the desktop's - * page; the desktop owns its size, storage, and window. - */ - readonly desktop: { readonly close: () => Promise } | null; - readonly profileId: string | undefined; - /** Set when a page in another tab opened this one with `window.open` or a link. */ - readonly openerTabId: string | undefined; - /** Finished downloads, newest last; files live until the tab closes. */ - readonly downloads: Array; - /** A page's open file picker, waiting for the controlling viewer's files. */ - fileChooser: { - readonly id: string; - readonly chooser: FileChooser; - readonly accept: string; - } | null; - dialog: Dialog | null; - setting: PreviewViewportSetting; - colorScheme: PreviewAppearancePreference; - zoomFactor: number; - loading: boolean; - closing: boolean; - recording: Recording | null; - initialNavigation: Promise | null; - /** Counts main-frame navigation requests, so late events can tell they were superseded. */ - navigationGeneration: number; - /** - * The latest main-frame navigation, when it aborted, to tell a navigation's - * download from a page's. The timer settles it if no download follows. - */ - abortedNavigation: { - readonly url: string; - readonly generation: number; - readonly timer: ReturnType; - } | null; - /** The latest queued start, so a stop can find a recording still starting. */ - recordingStart: Promise | null; - /** Serializes captures and recording start/stop. */ - captureLock: Promise; - /** Scaled captures rendering now; screencasts stay stopped meanwhile. */ - capturing: number; -} - -interface ServerDownload { - readonly id: string; - readonly fileName: string; - readonly path: string; - readonly sizeBytes: number; - readonly url: string; - readonly completedAt: string; -} - -/** One agent session and the whole server; each tab holds a renderer process. */ -/** - * Operations an agent may run on any tab in its thread, even while a human - * drives it. Evaluate is not one: page script can change anything, so it needs - * the same control as clicking. - */ -const READ_OPERATIONS: ReadonlySet = new Set([ - "status", - "snapshot", - "waitFor", -]); - -const AGENT_TAB_LIMIT = 8; -const SERVER_TAB_LIMIT = 32; -/** Agent tabs nobody watches close after this long without an agent request. */ -const AGENT_TAB_IDLE_MS = 30 * 60 * 1000; -const IDLE_SWEEP_INTERVAL = "1 minute"; - -const DOWNLOAD_LIMIT = 20; -/** How long an aborted navigation waits for the download that usually explains it. */ -const ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS = 5_000; -const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024; - -const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`; - -const pushBounded = ( - buffer: Array, - entry: A, - limit = ServerBrowserPage.DIAGNOSTIC_BUFFER_LIMIT, -) => { - buffer.push(entry); - if (buffer.length > limit) buffer.splice(0, buffer.length - limit); -}; - -const viewportSettingsEqual = (left: PreviewViewportSetting, right: PreviewViewportSetting) => - left._tag === right._tag && - (left._tag === "fill" || - (right._tag !== "fill" && left.width === right.width && left.height === right.height)); - -const fixedViewportSize = (setting: PreviewViewportSetting) => - setting._tag === "fill" ? null : { width: setting.width, height: setting.height }; - -const asRecord = (value: unknown): Record | null => - typeof value === "object" && value !== null ? (value as Record) : null; - -const num = (value: unknown, fallback = 0) => - typeof value === "number" && Number.isFinite(value) ? value : fallback; - -const modifiersOf = (message: Record) => { - const value = num(message.modifiers); - return Number.isInteger(value) && value >= 0 && value < 16 ? value : 0; -}; - -// Cmd shortcuts from Apple viewers are no editing shortcut for Linux or headless -// Chromium, so they carry the command. Ctrl already works natively on Linux, -// except copy and cut, which headless Chromium only runs as commands. -const editingCommand = (key: string, modifiers: number) => { - const modifier = modifiers & 0b0111; - if (modifier !== 2 && modifier !== 4) return null; - const lower = key.toLowerCase(); - const command = - lower === "c" - ? "copy" - : lower === "x" - ? "cut" - : modifier !== 4 - ? null - : lower === "a" - ? "selectAll" - : lower === "z" - ? modifiers & 8 - ? "redo" - : "undo" - : null; - return command ? { commands: [command] } : null; -}; - -const CLIPBOARD_TEXT_LIMIT = 1024 * 1024; -/** Copies reach the viewer only this soon after it last touched the page. */ -const CLIPBOARD_GESTURE_MS = 5_000; -const CLIPBOARD_BINDING = "__t3PreviewClipboard"; -// Headless Chromium shares one clipboard between every context, so pages -// report their own copies instead of the clipboard being read back. -const CLIPBOARD_SCRIPT = `(() => { - const send = (text) => { - if (typeof text !== "string" || text.length === 0) return; - try { globalThis.${CLIPBOARD_BINDING}?.(text.slice(0, ${CLIPBOARD_TEXT_LIMIT})); } catch {} - }; - const selection = () => { - const field = document.activeElement; - if ((field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) && field.selectionStart !== null) - return field.value.slice(field.selectionStart, field.selectionEnd ?? field.selectionStart); - return String(document.getSelection() ?? ""); - }; - // Bubbling to window runs after page handlers, before cut removes the selection. - const copied = (event) => - send(event.defaultPrevented ? event.clipboardData?.getData("text/plain") : selection()); - addEventListener("copy", copied); - addEventListener("cut", copied); - const proto = globalThis.Clipboard?.prototype; - if (!proto) return; - const writeText = proto.writeText; - proto.writeText = function (text) { - const result = writeText.call(this, text); - result.then(() => send(String(text)), () => {}); - return result; - }; - const write = proto.write; - proto.write = function (items) { - const result = write.call(this, items); - result.then(async () => { - const item = [...items].find((candidate) => candidate.types.includes("text/plain")); - if (item) send(await (await item.getType("text/plain")).text()); - }, () => {}); - return result; - }; -})();`; - const make = Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const host = { platform: yield* HostProcess.Platform, arch: yield* HostProcess.Architecture }; - const manager = yield* PreviewManager.PreviewManager; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const environment = yield* ServerEnvironment.ServerEnvironment; - const previewBrowser = yield* PreviewBrowser.PreviewBrowser; - const desktopChannel = yield* DesktopBrowserChannel.DesktopBrowserChannel; - const runFork = Effect.runForkWith(yield* Effect.context()); - const launchServices = yield* Effect.context(); - // The fix every host error names, rendered for how this server was launched. - const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); - - const tabs = new Map(); - const pendingTabs = new Map>(); - /** Sessions closed while their tab was still opening; the open discards its page. */ - const closedPendingTabs = new Set(); - /** Popup pages waiting for the tab their `opened` event creates. */ - const adoptedPages = new Map(); - /** Sessions the manager closed, so their tabs end for good. Pruned once dropped. */ - const closedSessions = new Set(); - let hostConnectionId: string | null = null; - let viewerResizeOrder = 0; - - const contexts = new ServerBrowserContexts({ - profilesDir: NodePath.join(config.stateDir, "server-browser", "profiles"), - executable: () => Effect.runPromise(previewBrowser.executable), - // Playwright's launch error drops Chrome's own output; its sandbox note survives. - diagnose: (executable, cause) => - Effect.runPromiseWith(launchServices)( - PreviewBrowserHost.diagnoseLaunchFailure({ - executable, - setupCommand, - output: /sandboxing failed/i.test(String(cause)) - ? PreviewBrowserHost.NO_SANDBOX_SIGNATURE - : "", - }), - ), - onContextClose: (context) => { - for (const tab of tabs.values()) { - if (tab.page.context() === context) dropTab(tab, true); - } - }, - }); - - const dialogStatus = (tab: ServerTab) => - tab.dialog - ? { - type: tab.dialog.type(), - message: tab.dialog.message().slice(0, 4000), - defaultValue: tab.dialog.defaultValue().slice(0, 4000), - } - : null; - - const broadcastControl = (tab: ServerTab) => { - for (const viewer of tab.viewers) - viewer.push({ - _tag: "control", - canOperate: viewer.canOperate, - controller: - tab.control.controller === viewer.id - ? "you" - : tab.control.controller !== null - ? "another-viewer" - : tab.control.agentId !== null - ? "agent" - : "unclaimed", - generation: tab.control.generation, - dialog: dialogStatus(tab), - }); - }; - - const resolveDialog = async (tab: ServerTab, input: PreviewAutomationDialogInput) => { - const dialog = tab.dialog; - if (!dialog) throw new Error("No dialog is pending."); - if (input.accept) await dialog.accept(input.promptText); - else await dialog.dismiss(); - if (tab.dialog === dialog) tab.dialog = null; - ServerBrowserPage.invalidateRefs(tab.page); - broadcastControl(tab); - }; - - const report = (tab: ServerTab, navStatus: PreviewNavStatus) => { - void tab.cdp - .send("Page.getNavigationHistory") - .catch(() => null) - .then((history) => { - const index = history?.currentIndex ?? 0; - const count = history?.entries.length ?? 0; - runFork( - manager - .reportStatus({ - threadId: tab.threadId, - tabId: tab.tabId, - serverControlled: true, - navStatus, - canGoBack: index > 0, - canGoForward: index < count - 1, - }) - .pipe(Effect.ignore), - ); - }); - }; - - const reportLoaded = async (tab: ServerTab) => { - const url = tab.page.url(); - // Chromium's error page loads after `requestfailed` and must not clear LoadFailed. - if (url === "about:blank" || url.startsWith("chrome-error://")) return; - const title = (await tab.page.title().catch(() => "")).slice(0, 512); - // A navigation that started while reading the title owns the status now. - if (tab.loading || tab.page.url() !== url) return; - report(tab, { _tag: "Success", url: url.slice(0, 2048), title }); - }; - - const reportLiveTabs = () => { - const connectionId = hostConnectionId; - if (connectionId === null) return; - runFork( - environment.getEnvironmentId.pipe( - Effect.flatMap((environmentId) => - broker.focusHost({ - clientId: SERVER_HOST_CLIENT_ID, - environmentId, - connectionId, - focused: true, - liveTabs: [...tabs.values()].map((tab) => ({ - threadId: tab.threadId, - tabId: tab.tabId, - visible: tab.viewers.size > 0, - })), - }), - ), - ), - ); - }; - - /** The CSS size the page lays out in: the viewport shrunk by the tab's zoom, as Chrome zooms. */ - const layoutSize = (tab: ServerTab) => { - const size = tab.page.viewportSize(); - if (!size || tab.desktop) return size; - return { - width: Math.max(1, Math.round(size.width / tab.zoomFactor)), - height: Math.max(1, Math.round(size.height / tab.zoomFactor)), - }; - }; - - /** - * Zooms a headless page as Chrome's zoom does: it lays out in fewer CSS - * pixels and draws each one larger, so frames keep their size. The desktop - * zooms the pages it renders itself. - */ - const applyZoom = async (tab: ServerTab) => { - if (tab.desktop) return; - const size = tab.page.viewportSize(); - if (!size) return; - if (tab.zoomFactor === 1) { - // Playwright's own override carries the plain viewport. - await tab.page.setViewportSize(size); - return; - } - const layout = layoutSize(tab)!; - await tab.cdp.send("Emulation.setDeviceMetricsOverride", { - width: layout.width, - height: layout.height, - deviceScaleFactor: RENDER_SCALE * tab.zoomFactor, - mobile: false, - }); - }; - - const broadcastViewport = (tab: ServerTab) => { - const size = layoutSize(tab); - if (!size) return; - for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size }); - }; - - const applySetting = async (tab: ServerTab, setting: PreviewViewportSetting) => { - tab.setting = setting; - // The desktop lays its webview out at the published setting itself. - if (tab.desktop) return; - const size = - fixedViewportSize(setting) ?? - [...tab.viewers] - .map((viewer) => viewer.requestedSize) - .filter((requested) => requested !== null) - .sort((left, right) => right.order - left.order)[0] ?? - UNATTACHED_FILL_VIEWPORT; - await tab.page.setViewportSize({ width: size.width, height: size.height }); - await applyZoom(tab); - broadcastViewport(tab); - }; - - /** Applies a tab's published appearance and, for headless tabs, zoom. */ - const applyRendering = async (tab: ServerTab, snapshot: PreviewSessionSnapshot) => { - const colorScheme = snapshot.colorScheme ?? "system"; - if (colorScheme !== tab.colorScheme) { - tab.colorScheme = colorScheme; - await tab.page.emulateMedia({ colorScheme: colorScheme === "system" ? null : colorScheme }); - } - const zoomFactor = snapshot.zoomFactor ?? 1; - if (zoomFactor !== tab.zoomFactor && !tab.desktop) { - tab.zoomFactor = zoomFactor; - await applyZoom(tab); - broadcastViewport(tab); - } - }; - - /** Whether the preview session for a tab still exists. */ - const sessionOpen = (tab: ServerTab) => !closedSessions.has(tabKey(tab.threadId, tab.tabId)); - - const dropTab = (tab: ServerTab, closeSession: boolean) => { - const key = tabKey(tab.threadId, tab.tabId); - if (tabs.get(key) !== tab) return; - tabs.delete(key); - tab.closing = true; - clearAbortedNavigation(tab); - // A desktop page outlives the connection unless its session closed with it. - const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; - for (const viewer of tab.viewers) viewer.push({ _tag: end }); - void tab.control.close().catch(constVoid); - // The desktop owns its page; letting go only ends this connection. - if (tab.desktop) void tab.desktop.close().catch(constVoid); - else void tab.page.close().catch(constVoid); - if (tab.isolatedContext) void tab.page.context().close().catch(constVoid); - void tab.recording?.encoder.close().catch(constVoid); - void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid); - reportLiveTabs(); - if (closeSession) { - runFork(manager.close({ threadId: tab.threadId, tabId: tab.tabId }).pipe(Effect.ignore)); - } - }; - - /** - * With a desktop app attached, every tab of this server renders there, so a - * new tab waits for its `` instead of launching headless. - */ - const desktopRenders = (snapshot: PreviewSessionSnapshot) => - desktopChannel.available - ? Effect.runPromise( - desktopChannel.awaitAttached( - { threadId: snapshot.threadId, tabId: snapshot.tabId }, - DESKTOP_ATTACH_TIMEOUT, - ), - ) - : Promise.resolve(false); - - /** Connects to the desktop's page for a tab, which it renders and the server drives. */ - const connectDesktop = async (snapshot: PreviewSessionSnapshot) => { - const scope = await Effect.runPromise(Scope.make()); - try { - const endpoint = await Effect.runPromise( - desktopChannel - .endpoint({ threadId: snapshot.threadId, tabId: snapshot.tabId }) - .pipe(Scope.provide(scope)), - ); - const connected = await contexts.connectDesktopPage(endpoint); - return { - page: connected.page, - close: async () => { - await connected.browser.close().catch(constVoid); - await Effect.runPromise(Scope.close(scope, Exit.void)); - }, - }; - } catch (cause) { - await Effect.runPromise(Scope.close(scope, Exit.void)); - throw cause; - } - }; - - const createTab = async (snapshot: PreviewSessionSnapshot): Promise => { - const adopted = adoptedPages.get(tabKey(snapshot.threadId, snapshot.tabId)); - adoptedPages.delete(tabKey(snapshot.threadId, snapshot.tabId)); - const desktop = - adopted === undefined && (await desktopRenders(snapshot)) - ? await connectDesktop(snapshot) - : null; - // An agent tab without a profile (no client reported one) keeps throwaway storage. - const isolatedContext = - adopted === undefined && - desktop === null && - ((snapshot.automationOwner !== undefined && snapshot.profileId === undefined) || - snapshot.profileId === INCOGNITO_BROWSER_PROFILE_ID); - const context = - adopted?.page.context() ?? - desktop?.page.context() ?? - (await contexts.contextFor( - snapshot.profileId ?? "default", - isolatedContext ? tabKey(snapshot.threadId, snapshot.tabId) : undefined, - )); - if (adopted?.page.isClosed()) throw new Error("The popup closed before it opened."); - // The desktop page already has its own clipboard; the bridge script is for headless tabs. - if (!desktop) await prepareContext(context); - const page = adopted?.page ?? desktop?.page ?? (await context.newPage()); - const cdp = await context.newCDPSession(page); - // The desktop's page is already a normal browser. A popup's first request - // went out before it became a tab; everything after presents as Chrome. - if (!desktop) await presentAsChrome(cdp, host); - page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS); - page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS); - const control = new SessionControl(snapshot.automationOwner ?? null, () => - ServerBrowserPage.invalidateRefs(page), - ); - const tab: ServerTab = { - threadId: ThreadId.make(snapshot.threadId), - tabId: snapshot.tabId, - page, - cdp, - createdAt: Date.now(), - usedAt: Date.now(), - viewers: new Set(), - consoleEntries: [], - networkEntries: [], - actionTimeline: [], - control, - isolatedContext, - desktop: desktop === null ? null : { close: desktop.close }, - profileId: snapshot.profileId, - openerTabId: adopted?.openerTabId, - downloads: [], - fileChooser: null, - dialog: null, - setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, - colorScheme: "system", - zoomFactor: 1, - loading: false, - closing: false, - recording: null, - recordingStart: null, - initialNavigation: null, - navigationGeneration: 0, - abortedNavigation: null, - captureLock: Promise.resolve(), - capturing: 0, - }; - if (!desktop) { - await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT); - } - await applyRendering(tab, snapshot); - const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => - request.isNavigationRequest() && request.frame() === page.mainFrame(); - const navigationGenerations = new WeakMap(); - page.on("request", (request) => { - if (!isMainNavigation(request)) return; - navigationGenerations.set(request, ++tab.navigationGeneration); - clearAbortedNavigation(tab); - tab.loading = true; - report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" }); - }); - page.on("load", () => { - tab.loading = false; - void reportLoaded(tab); - }); - page.on("framenavigated", (frame) => { - // Same-document navigations (SPA routes) fire no load event. - if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab); - }); - page.on("requestfailed", (request) => { - const errorText = request.failure()?.errorText ?? ""; - pushBounded(tab.networkEntries, { - url: request.url(), - method: request.method(), - status: null, - failed: true, - errorText, - timestamp: new Date().toISOString(), - }); - if (!isMainNavigation(request)) return; - // A navigation a newer one replaced leaves the status to the newer one. A - // popup's first request can predate the listener, so it is untracked and - // superseded by any tracked one. - const generation = navigationGenerations.get(request) ?? 0; - if (generation !== tab.navigationGeneration) return; - // An aborted navigation is usually a download; its `download` event settles the status. - if (errorText.includes("ERR_ABORTED")) { - const url = request.url(); - const navigationGeneration = tab.navigationGeneration; - clearAbortedNavigation(tab); - tab.abortedNavigation = { - url, - generation: navigationGeneration, - timer: setTimeout(() => { - if (tab.abortedNavigation?.generation !== navigationGeneration) return; - tab.abortedNavigation = null; - if (!tab.closing) settleAbortedNavigation(tab, navigationGeneration, url, undefined); - }, ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS), - }; - return; - } - tab.loading = false; - const { code, description } = ServerBrowserPage.parseNetError(errorText); - report(tab, { - _tag: "LoadFailed", - url: request.url().slice(0, 2048), - title: "", - code, - description, - }); - }); - page.on("response", (response) => { - pushBounded(tab.networkEntries, { - url: response.url(), - method: response.request().method(), - status: response.status(), - failed: false, - timestamp: new Date().toISOString(), - }); - }); - page.on("console", (message) => { - pushBounded(tab.consoleEntries, { - level: message.type(), - text: message.text().slice(0, 2_000), - timestamp: new Date().toISOString(), - }); - }); - page.on("dialog", (dialog) => { - tab.dialog = dialog; - broadcastControl(tab); - }); - page.on("download", (download) => void saveDownload(tab, download)); - page.on("filechooser", (chooser) => void offerFileChooser(tab, chooser)); - // Popups become tabs and keep `window.opener`, so sign-in popups can report back. - page.on("popup", (popup) => void adoptPopup(tab, popup)); - // Playwright cannot reload a crashed page, so it must leave the tab list. - const key = tabKey(tab.threadId, tab.tabId); - if (closedPendingTabs.delete(key)) { - await control.close().catch(constVoid); - if (desktop) await desktop.close().catch(constVoid); - else await page.close().catch(constVoid); - if (isolatedContext) await context.close().catch(constVoid); - throw new ServerBrowserTabNotFoundError({ threadId: tab.threadId, tabId: tab.tabId }); - } - page.on("close", () => dropTab(tab, true)); - page.on("crash", () => dropTab(tab, true)); - tabs.set(key, tab); - reportLiveTabs(); - // A popup is already loading its own URL, and the desktop loads its tab's. - if (!adopted && !desktop && snapshot.navStatus._tag === "Loading") { - tab.initialNavigation = page - .goto(snapshot.navStatus.url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS }) - .then(constVoid); - // Background creation keeps the failed tab; automation awaits the original error. - void tab.initialNavigation.catch(constVoid); - } - return tab; - }; - - const downloadsRoot = NodePath.join(config.stateDir, "server-browser", "downloads"); - // Tab ids are opaque server strings; hashing keeps them out of the path. - const downloadDir = (tab: ServerTab) => - NodePath.join( - downloadsRoot, - NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"), - ); - - const clearAbortedNavigation = (tab: ServerTab) => { - if (tab.abortedNavigation !== null) clearTimeout(tab.abortedNavigation.timer); - tab.abortedNavigation = null; - }; - - /** - * Settles the status of a navigation that aborted, usually into a download. - * A tab opened straight to the file has no page to show, so it reports the - * file with its download; a page that linked to it keeps showing itself. - */ - const settleAbortedNavigation = ( - tab: ServerTab, - generation: number, - url: string, - offered: { readonly id: string; readonly fileName: string } | undefined, - ) => { - // A newer navigation owns the status. - if (tab.navigationGeneration !== generation) return; - tab.loading = false; - if (tab.page.url() !== "about:blank") { - void reportLoaded(tab); - return; - } - report(tab, { - _tag: "LoadFailed", - url: url.slice(0, 2048), - title: "", - code: -3, - description: "ERR_ABORTED", - ...(offered === undefined ? {} : { download: offered }), - }); - }; - - const saveDownload = async (tab: ServerTab, download: Download) => { - const id = NodeCrypto.randomUUID(); - const path = NodePath.join(downloadDir(tab), id); - const navigation = - download.url() === tab.abortedNavigation?.url ? tab.abortedNavigation.generation : null; - if (navigation !== null) clearAbortedNavigation(tab); - let offered: { readonly id: string; readonly fileName: string } | undefined; - try { - if (await download.failure()) return; - await NodeFSP.mkdir(downloadDir(tab), { recursive: true }); - await download.saveAs(path); - const { size } = await NodeFSP.stat(path); - if (tab.closing || size > DOWNLOAD_MAX_BYTES) { - await NodeFSP.rm(path, { force: true }); - return; - } - const saved: ServerDownload = { - id, - fileName: download.suggestedFilename().slice(0, 255) || "download", - path, - sizeBytes: size, - url: download.url().slice(0, 2048), - completedAt: new Date().toISOString(), - }; - tab.downloads.push(saved); - for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) { - await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid); - } - offered = { id, fileName: saved.fileName }; - // A tab whose own address was the file shows it in place (see - // settleAbortedNavigation), unless it navigated on while the file saved. - const shownInTab = - navigation === tab.navigationGeneration && tab.page.url() === "about:blank"; - if (shownInTab) return; - // Only the person driving the page gets the file offered; agents read it from status. - const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); - controller?.push({ - _tag: "download", - id, - fileName: saved.fileName, - sizeBytes: saved.sizeBytes, - }); - } catch (cause) { - await NodeFSP.rm(path, { force: true }).catch(constVoid); - runFork(Effect.logWarning("server preview download failed", { cause })); - } finally { - if (navigation !== null && !tab.closing) { - settleAbortedNavigation(tab, navigation, download.url(), offered); - } - } - }; - - const fileChooserMessage = (tab: ServerTab): ServerBrowserViewerOutput | null => - tab.fileChooser - ? { - _tag: "fileChooser", - id: tab.fileChooser.id, - multiple: tab.fileChooser.chooser.isMultiple(), - accept: tab.fileChooser.accept, - } - : null; - - const offerFileChooser = async (tab: ServerTab, chooser: FileChooser) => { - const previous = tab.fileChooser; - const accept = - (await chooser - .element() - .getAttribute("accept") - .catch(() => null)) ?? ""; - // A newer picker replaces an unanswered one, as a real browser allows only one. - if (previous) closeFileChooser(tab); - tab.fileChooser = { id: NodeCrypto.randomUUID(), chooser, accept: accept.slice(0, 1024) }; - pushFileChooser(tab); - }; - - const pushFileChooser = (tab: ServerTab) => { - const message = fileChooserMessage(tab); - const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); - if (message) controller?.push(message); - }; - - const closeFileChooser = (tab: ServerTab) => { - const open = tab.fileChooser; - if (!open) return; - tab.fileChooser = null; - for (const viewer of tab.viewers) viewer.push({ _tag: "fileChooserClosed", id: open.id }); - }; - - /** Hands uploaded files to the page's open picker; an empty list cancels it. */ - const setChooserFiles = async ( - input: Parameters[0], - ) => { - const tab = tabs.get(tabKey(input.threadId, input.tabId)); - const open = tab?.fileChooser; - if (!tab || !open || open.id !== input.chooserId) return false; - if (input.files.length > 0) { - await open.chooser.setFiles( - open.chooser.isMultiple() ? [...input.files] : input.files.slice(0, 1), - ); - } - if (tab.fileChooser === open) closeFileChooser(tab); - return true; - }; - /** The agent's files go to a named file input, or else to the page's open picker. */ - const uploadFiles = async (tab: ServerTab, input: PreviewAutomationUploadInput) => { - const relative = input.paths.find((path) => !NodePath.isAbsolute(path)); - if (relative !== undefined) - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationExecutionError", - `Upload paths must be absolute: ${relative}`, - ); - if (await ServerBrowserPage.setInputFiles(tab.page, input)) return undefined; - const open = tab.fileChooser; - if (!open) - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationExecutionError", - "No file picker is open. Click the page's upload control first, or pass the file input's locator.", - ); - if (input.paths.length > 1 && !open.chooser.isMultiple()) - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationExecutionError", - "This file picker accepts one file.", - ); - if (input.paths.length > 0) - await open.chooser.setFiles([...input.paths], { - timeout: input.timeoutMs ?? NAVIGATION_TIMEOUT_MS, - }); - if (tab.fileChooser === open) closeFileChooser(tab); - return undefined; - }; - - const answerFileChooser: ServerBrowser["Service"]["answerFileChooser"] = (input) => - Effect.promise(() => setChooserFiles(input).catch(() => false)); - - const openDownload: ServerBrowser["Service"]["openDownload"] = (input) => - Effect.sync(() => { - const download = tabs - .get(tabKey(input.threadId, input.tabId)) - ?.downloads.find((candidate) => candidate.id === input.downloadId); - return download === undefined - ? Option.none() - : Option.some({ path: download.path, fileName: download.fileName }); - }); - - const preparedContexts = new WeakSet(); - const prepareContext = async (context: BrowserContext) => { - if (preparedContexts.has(context)) return; - preparedContexts.add(context); - await context.grantPermissions(["clipboard-write"]); - await context.exposeBinding(CLIPBOARD_BINDING, ({ page }, text: unknown) => { - if (typeof text !== "string") return; - const tab = [...tabs.values()].find((candidate) => candidate.page === page); - const controller = tab - ? [...tab.viewers].find((viewer) => viewer.id === tab.control.controller) - : undefined; - if (!controller || Date.now() - controller.inputAt > CLIPBOARD_GESTURE_MS) return; - controller.push({ _tag: "clipboard", text: text.slice(0, CLIPBOARD_TEXT_LIMIT) }); - }); - await context.addInitScript(CLIPBOARD_SCRIPT); - }; - - const adoptPopup = async (opener: ServerTab, popup: Page) => { - const agentId = opener.control.agentId; - // A popup past an agent's limit closes; its page sees window.open return a closed window. - if (opener.closing || (agentId !== null && atTabLimit(agentId))) { - await popup.close().catch(constVoid); - return; - } - const url = popup.url(); - // Captured now: the person whose click opened the popup gets switched to it. - const controller = [...opener.viewers].find( - (viewer) => viewer.id === opener.control.controller, - ); - await Effect.runPromise( - manager.open({ - threadId: opener.threadId, - ...(/^https?:/i.test(url) ? { url } : {}), - runtime: "server", - ...(opener.profileId === undefined ? {} : { profileId: opener.profileId }), - // Agent popups stay with the agent and only float when it asks, like its own opens. - ...(opener.control.agentId === null - ? {} - : { automationOwner: opener.control.agentId, reveal: false }), - beforePublish: (snapshot) => - adoptedPages.set(tabKey(snapshot.threadId, snapshot.tabId), { - page: popup, - openerTabId: opener.tabId, - }), - }), - ).then( - (snapshot) => { - // The opener stays open behind it, so `window.opener` can still report back. - if (opener.control.agentId === null) - controller?.push({ _tag: "popup", tabId: snapshot.tabId }); - }, - async () => { - await popup.close().catch(constVoid); - }, - ); - }; - - const ensureTab = (snapshot: PreviewSessionSnapshot): Promise => { - const key = tabKey(snapshot.threadId, snapshot.tabId); - const pending = pendingTabs.get(key); - if (pending) return pending; - const existing = tabs.get(key); - if (existing) return Promise.resolve(existing); - const opening = createTab(snapshot) - .catch((cause: unknown) => { - runFork( - Effect.logWarning( - isHostSetupError(cause) ? cause.message : "server preview tab failed to start", - { cause }, - ), - ); - throw cause; - }) - .finally(() => { - pendingTabs.delete(key); - closedPendingTabs.delete(key); - }); - pendingTabs.set(key, opening); - return opening; - }; - - const findTab = (threadId: string, tabId: string) => - Effect.gen(function* () { - const existing = tabs.get(tabKey(threadId, tabId)); - if (existing) return existing; - const { sessions } = yield* manager.list({ threadId: ThreadId.make(threadId) }); - const snapshot = sessions.find( - (session) => session.tabId === tabId && session.runtime === "server", - ); - if (!snapshot) return yield* new ServerBrowserTabNotFoundError({ threadId, tabId }); - return yield* Effect.tryPromise({ - try: () => ensureTab(snapshot), - catch: (cause) => (isTabNotFound(cause) ? cause : new ServerBrowserLaunchError({ cause })), - }); - }); - - const atTabLimit = (agentSessionId: string) => - tabs.size + pendingTabs.size >= SERVER_TAB_LIMIT || - [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length >= - AGENT_TAB_LIMIT; - - const assertTabCapacity = (agentSessionId: string) => { - if (atTabLimit(agentSessionId)) - throw new BrowserControlInterrupted( - `Too many server browser tabs are open (${AGENT_TAB_LIMIT} per agent session, ${SERVER_TAB_LIMIT} per server). Close one with t3_preview_close or reuse one from preview_status tabs.`, - "tabLimit", - ); - }; - - /** Closes agent tabs that no one watches and the agent stopped using. */ - const closeIdleAgentTabs = () => { - const cutoff = Date.now() - AGENT_TAB_IDLE_MS; - for (const tab of tabs.values()) { - if (tab.control.agentId !== null && tab.viewers.size === 0 && tab.usedAt < cutoff) - dropTab(tab, true); - } - }; - - /** - * The tab a request without a tabId means: the caller's newest tab, else the - * thread's tab the user is looking at or used last, so "this page" works. - */ - const latestThreadTab = (threadId: string, agentSessionId?: string) => { - const threadTabs = [...tabs.values()].filter((tab) => tab.threadId === threadId); - return ( - threadTabs - .filter((tab) => tab.control.agentId === agentSessionId) - .sort((left, right) => right.createdAt - left.createdAt)[0] ?? - threadTabs.sort( - (left, right) => - Number(right.viewers.size > 0) - Number(left.viewers.size > 0) || - right.usedAt - left.usedAt, - )[0] - ); - }; - - const tabOwner = (tab: ServerTab) => - tab.control.controller !== null - ? ("human" as const) - : tab.control.agentId !== null - ? ("agent" as const) - : ("unclaimed" as const); - - /** The latest profile list a client reported; agents choose from it. */ - let reportedProfiles: { - readonly profiles: ReadonlyArray; - readonly defaultProfileId: string; - } | null = null; - - const reportProfiles: ServerBrowser["Service"]["reportProfiles"] = (input) => - Effect.sync(() => { - const profiles = resolveBrowserProfiles(input.profiles); - reportedProfiles = { - profiles, - defaultProfileId: - findBrowserProfile(profiles, input.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID, - }; - }); - - const profileStatus = () => { - const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES; - return { - profiles: profiles.map((profile) => ({ - id: profile.id, - name: profile.name, - ...(profile.kind === "incognito" ? { incognito: true } : {}), - })), - ...(reportedProfiles ? { defaultProfileId: reportedProfiles.defaultProfileId } : {}), - }; - }; - - /** Matches an id, then a case-insensitive name. Omitted means the reported default. */ - const resolveOpenProfile = (requested: string | undefined): string | undefined => { - if (requested === undefined) return reportedProfiles?.defaultProfileId; - const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES; - const wanted = requested.toLowerCase(); - const match = - findBrowserProfile(profiles, requested) ?? - profiles.find((profile) => profile.name.toLowerCase() === wanted); - if (match) return match.id; - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationUnknownProfileError", - `No browser profile is named "${requested}". Use one of: ${profiles - .map((profile) => `${profile.name} (id ${profile.id})`) - .join(", ")}.`, - ); - }; - - const statusWithTitle = async ( - tab: ServerTab | undefined, - agentSessionId?: string, - ): Promise => { - if (!tab) { - return { - available: false, - visible: false, - tabId: null, - url: null, - title: null, - loading: false, - ...profileStatus(), - }; - } - const url = tab.page.url(); - const viewport = tab.page.viewportSize(); - const status = { - available: true, - visible: tab.viewers.size > 0, - tabId: tab.tabId, - url: url === "about:blank" ? null : url, - title: null, - loading: tab.loading, - control: { - owner: tabOwner(tab), - ownedByCaller: tab.control.agentId === agentSessionId, - generation: tab.control.generation, - }, - dialog: dialogStatus(tab), - fileChooser: tab.fileChooser - ? { multiple: tab.fileChooser.chooser.isMultiple(), accept: tab.fileChooser.accept } - : null, - viewportSetting: tab.setting, - ...(viewport ? { viewport } : {}), - tabs: [...tabs.values()] - .filter((candidate) => candidate.threadId === tab.threadId) - .map((candidate) => ({ - tabId: candidate.tabId, - url: candidate.page.url() === "about:blank" ? null : candidate.page.url(), - ...(candidate.openerTabId === undefined ? {} : { openerTabId: candidate.openerTabId }), - owner: tabOwner(candidate), - ownedByCaller: candidate.control.agentId === agentSessionId, - visible: candidate.viewers.size > 0, - ...(candidate.profileId === undefined ? {} : { profileId: candidate.profileId }), - })), - ...profileStatus(), - downloads: tab.downloads.map(({ fileName, path, sizeBytes, url, completedAt }) => ({ - fileName, - path, - sizeBytes, - url, - completedAt, - })), - }; - if (status.url === null || tab.dialog) return status; - return { ...status, title: (await tab.page.title().catch(() => "")) || null }; - }; - - const resolveNavigationUrl = (input: PreviewAutomationNavigateInput) => { - if (input.url !== undefined) return normalizePreviewUrl(input.url); - const target = input.target!; - if (target.kind === "url") return normalizePreviewUrl(target.url); - // The browser runs inside the environment, so its ports are loopback. - const path = target.path ?? ""; - return `${target.protocol ?? "http"}://localhost:${target.port}${path.startsWith("/") || path === "" ? path : `/${path}`}`; - }; - - const navigate = async ( - tab: ServerTab, - url: string, - readiness: "load" | "domContentLoaded" | "none", - timeout: number, - ) => { - const navigation = tab.page.goto(url, { - timeout, - waitUntil: - readiness === "domContentLoaded" - ? "domcontentloaded" - : readiness === "none" - ? "commit" - : "load", - }); - if (readiness === "none") { - tab.control.track(navigation); - return; - } - await navigation.catch((cause: unknown) => { - const message = cause instanceof Error ? cause.message : String(cause); - if (message.includes("Download is starting")) { - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationExecutionError", - `${url} is a file this browser cannot show, so it was downloaded instead. preview_status lists it under downloads.`, - ); - } - if (/ERR_[A-Z_]+/.test(message)) { - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationExecutionError", - `Navigation to ${url} failed: ${ServerBrowserPage.parseNetError(message).description}`, - ); - } - throw cause; - }); - }; - - const withCaptureLock = (tab: ServerTab, operation: () => Promise): Promise => { - const run = tab.captureLock.then(() => { - if (tab.closing) throw new Error("The preview tab closed."); - return operation(); - }); - tab.captureLock = run.then(constVoid, constVoid); - return run; - }; - - const startRecording = (tab: ServerTab): Promise => { - const started = withCaptureLock(tab, async () => { - if (tab.recording) return tab.recording; - const encoder = await contexts.scratchPage(); - let session: CDPSession | null = null; - try { - await encoder.evaluate(ServerBrowserPage.RECORDING_ENCODER_SCRIPT); - // Seed idle pages before returning so an immediate stop has a frame to encode. - const firstFrame = await ServerBrowserPage.captureViewport(tab.page, tab.cdp, { - format: "jpeg", - quality: RECORDING_SCREENCAST.quality, - scale: 1, - }); - await encoder.evaluate( - ([data, width]) => - (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), - [firstFrame, tab.page.viewportSize()?.width ?? UNATTACHED_FILL_VIEWPORT.width] as const, - ); - const opened = await tab.page.context().newCDPSession(tab.page); - session = opened; - const framesInFlight = new Set>(); - opened.on("Page.screencastFrame", (frame) => { - const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth; - const delivered: Promise = encoder - .evaluate( - ([data, width]) => - (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), - [frame.data, cssWidth] as const, - ) - .then(async (accepted) => { - if (!accepted) await opened.send("Page.stopScreencast"); - }) - .catch(constVoid) - .finally(() => { - framesInFlight.delete(delivered); - void opened - .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) - .catch(constVoid); - }); - framesInFlight.add(delivered); - }); - await opened.send("Page.startScreencast", RECORDING_SCREENCAST); - if (tab.closing) throw new Error("The tab closed while the recording started."); - const recording: Recording = { - encoder, - session: opened, - startedAt: new Date().toISOString(), - framesInFlight, - }; - tab.recording = recording; - return recording; - } catch (cause) { - // A start that fails partway must not leave its encoder page behind. - await encoder.close().catch(constVoid); - await session?.detach().catch(constVoid); - throw cause; - } - }).finally(() => { - if (tab.recordingStart === started) tab.recordingStart = null; - }); - tab.recordingStart = started; - return started; - }; - - // Scaled captures repaint every screencast; pause them to avoid leaking that frame. - const withScreencastsPaused = (tab: ServerTab, capture: () => Promise): Promise => - withCaptureLock(tab, async () => { - tab.capturing += 1; - const recording = tab.recording; - try { - await Promise.all([ - ...[...tab.viewers].map((viewer) => viewer.pause()), - recording?.session.send("Page.stopScreencast").catch(constVoid), - ]); - return await capture(); - } finally { - tab.capturing -= 1; - // Viewers that attached during the capture start here too. - await Promise.all([ - ...[...tab.viewers].map((viewer) => viewer.resume()), - recording && tab.recording === recording - ? recording.session.send("Page.startScreencast", RECORDING_SCREENCAST).catch(constVoid) - : undefined, - ]); - } - }); - - const stopRecording = (tab: ServerTab) => - withCaptureLock(tab, async () => { - const recording = tab.recording; - if (!recording) { - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationRecordingNotActiveError", - "No recording is active for this tab.", - ); - } - let mimeType: string | null; - const chunks: Array = []; - try { - await recording.session.send("Page.stopScreencast").catch(constVoid); - // The last frames may still be on their way into the encoder. - await Promise.all(recording.framesInFlight); - await recording.session.detach().catch(constVoid); - const stopped = await recording.encoder.evaluate(() => - (globalThis as unknown as EncoderWindow).__t3Recorder.stop(), - ); - mimeType = stopped.mimeType; - // Checked before the transfer so an oversized video never lands in this process. - if (stopped.bytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationRecordingTooLargeError", - "The recording is larger than the attachment limit.", - ); - } - for (let index = 0; index < stopped.count; index += 1) { - const chunk = await recording.encoder.evaluate( - (chunkIndex) => (globalThis as unknown as EncoderWindow).__t3Recorder.chunk(chunkIndex), - index, - ); - chunks.push(Buffer.from(chunk, "base64")); - } - } finally { - await recording.encoder.close().catch(constVoid); - tab.recording = null; - } - const data = Buffer.concat(chunks); - if (!mimeType || data.byteLength === 0) { - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationExecutionError", - "The recording captured no frames.", - ); - } - // Use the desktop upload location so the MCP handler can claim the recording. - const extension = mimeType.startsWith("video/mp4") ? "mp4" : "webm"; - const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}-${extension}`; - const path = NodePath.join(config.attachmentsDir, `${pendingId}.${extension}`); - await NodeFSP.mkdir(config.attachmentsDir, { recursive: true }); - await NodeFSP.writeFile(path, data); - return { - id: pendingId, - tabId: tab.tabId, - path, - mimeType: mimeType.split(";")[0]!, - sizeBytes: data.byteLength, - createdAt: new Date().toISOString(), - uploadedAttachmentId: pendingId, - }; - }); - - let pointerSequence = 0; - /** - * Shows viewers and recordings where the agent is about to act. Nobody is - * watching a headless agent tab, so it pays no glide delay. - */ - const pointerFor = - (tab: ServerTab): ServerBrowserPage.PointerReporter => - async ({ x, y }, phase) => { - const encoder = tab.recording?.encoder; - // A desktop-rendered tab is always on screen in the desktop app. - if (tab.viewers.size === 0 && !encoder && !tab.desktop) return; - const show = (next: "move" | "click") => { - const sequence = ++pointerSequence; - for (const viewer of tab.viewers) - viewer.push({ _tag: "pointer", phase: next, x, y, sequence }); - if (tab.desktop) - runFork( - desktopChannel.pointer( - { threadId: tab.threadId, tabId: tab.tabId }, - { phase: next, x, y }, - ), - ); - void encoder - ?.evaluate( - ([px, py, click]) => - (globalThis as unknown as EncoderWindow).__t3Recorder?.cursor(px, py, click), - [x, y, next === "click"] as const, - ) - .catch(constVoid); - }; - show("move"); - await sleepUntil(Date.now() + AGENT_CURSOR_MOVE_MS); - if (phase !== "click") return; - show("click"); - await sleepUntil(Date.now() + AGENT_CURSOR_CLICK_LEAD_MS); - }; - - const recordAction = (tab: ServerTab, action: string, run: () => Promise): Promise => { - const event: { - -readonly [K in keyof PreviewAutomationActionEvent]: PreviewAutomationActionEvent[K]; - } = { - id: NodeCrypto.randomUUID(), - action, - status: "running", - startedAt: new Date().toISOString(), - }; - pushBounded(tab.actionTimeline, event, ACTION_TIMELINE_LIMIT); - return run().then( - (result) => { - event.status = "succeeded"; - event.completedAt = new Date().toISOString(); - return result; - }, - (cause: unknown) => { - event.status = "failed"; - event.completedAt = new Date().toISOString(); - event.error = cause instanceof Error ? cause.message.split("\n")[0] : String(cause); - throw cause; - }, - ); - }; - - const requireTab = async (request: PreviewAutomationRequest) => { - if (!request.agentSessionId) - throw new BrowserControlInterrupted("The agent session is missing. Reconnect the provider."); - const owned = [...tabs.values()].filter( - (tab) => tab.threadId === request.threadId && tab.control.agentId === request.agentSessionId, - ); - if (!request.tabIdExplicit && owned.length > 1) - throw new BrowserControlInterrupted( - "Multiple tabs belong to this agent session. Pass a tabId from preview_open or preview_status tabs.", - "tabRequired", - ); - const tab = - request.tabId === undefined - ? latestThreadTab(request.threadId, request.agentSessionId) - : await Effect.runPromise( - findTab(request.threadId, request.tabId).pipe(Effect.orElseSucceed(constVoid)), - ); - if (!tab) { - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationTabNotFoundError", - "No server preview tab is open for this thread. Call preview_open first.", - ); - } - // Any tab in the thread can be read; acting is checked by its control. - if (!READ_OPERATIONS.has(request.operation) && !tab.control.agentMayAct(request.agentSessionId)) - throw new BrowserControlInterrupted( - "This tab belongs to another agent session. You can read it, but open your own tab to act.", - "agentMismatch", - ); - return tab; - }; - - /** - * Any request, even a failed one, keeps the agent's tabs on the thread from - * idling out. The tab it targeted becomes the thread's most recently used. - */ - const markUsed = (request: PreviewAutomationRequest) => { - const now = Date.now(); - for (const tab of tabs.values()) { - if ( - tab.threadId === request.threadId && - (tab.control.agentId === request.agentSessionId || tab.tabId === request.tabId) - ) - tab.usedAt = now; - } - }; - - const runOperation = async (request: PreviewAutomationRequest): Promise => { - const input = request.input; - switch (request.operation) { - case "status": - return statusWithTitle( - request.tabId === undefined - ? latestThreadTab(request.threadId, request.agentSessionId) - : tabs.get(tabKey(request.threadId, request.tabId)), - request.agentSessionId, - ); - case "open": { - if (!request.agentSessionId) - throw new BrowserControlInterrupted( - "The agent session is missing. Reconnect the provider.", - ); - const open = input as PreviewAutomationOpenInput; - const url = open.url === undefined ? undefined : normalizePreviewUrl(open.url); - const reuse = open.reuseExistingTab ?? true; - if ( - reuse && - !request.tabIdExplicit && - [...tabs.values()].filter( - (tab) => - tab.threadId === request.threadId && tab.control.agentId === request.agentSessionId, - ).length > 1 - ) - throw new BrowserControlInterrupted( - "Multiple tabs are open. Pass tabId or reuseExistingTab=false.", - "tabRequired", - ); - // A tab still launching exists only as a session, so resolve it like a viewer would. - const found = - reuse && request.tabId !== undefined - ? await Effect.runPromise( - findTab(request.threadId, request.tabId).pipe( - Effect.catchTags({ - ServerBrowserTabNotFoundError: () => Effect.succeed(undefined), - }), - ), - ) - : undefined; - // Only an explicit tabId reuses a tab this session did not open; a tab - // it last read (such as the user's) is not taken over implicitly. - const existing = - found && (request.tabIdExplicit || found.control.agentId === request.agentSessionId) - ? found - : undefined; - const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS); - const profileId = existing ? undefined : resolveOpenProfile(open.profileId); - if (!existing) { - closeIdleAgentTabs(); - assertTabCapacity(request.agentSessionId); - } - const tab = - existing ?? - (await ensureTab( - await Effect.runPromise( - manager.open({ - threadId: request.threadId, - ...(url ? { url } : {}), - ...(profileId === undefined ? {} : { profileId }), - runtime: "server", - reveal: false, - automationOwner: request.agentSessionId, - }), - ), - )); - if (existing?.dialog) - throw new BrowserControlInterrupted( - "A browser dialog is pending. Read preview_status and use preview_dialog first.", - "dialogPending", - ); - return tab.control.agent(request.agentSessionId, async () => { - if (tab.dialog) - throw new BrowserControlInterrupted( - "A browser dialog is pending. Read preview_status and use preview_dialog first.", - "dialogPending", - ); - if (existing) { - if (url) await navigate(tab, url, "load", navigationTimeout); - } else { - // Await the original navigation failure even though background creation keeps the tab. - await tab.initialNavigation; - } - const reveal = open.open ?? open.show; - if (reveal !== false) { - await Effect.runPromise( - manager.requestReveal({ - threadId: tab.threadId, - tabId: tab.tabId, - force: reveal === true, - }), - ); - } - if (!existing && url) { - await tab.page - .waitForLoadState("load", { timeout: navigationTimeout }) - .catch(constVoid); - } - return statusWithTitle(tab, request.agentSessionId); - }); - } - case "recordingStop": { - if ( - !request.tabIdExplicit && - [...tabs.values()].filter( - (candidate) => - candidate.threadId === request.threadId && - candidate.control.agentId === request.agentSessionId, - ).length > 1 - ) - throw new BrowserControlInterrupted( - "Multiple tabs belong to this agent session. Pass a tabId from preview_open or preview_status tabs.", - "tabRequired", - ); - const recordings = [...tabs.values()].filter( - (candidate) => - candidate.threadId === request.threadId && - candidate.control.agentMayAct(request.agentSessionId ?? "") && - (candidate.recording || candidate.recordingStart), - ); - const targetTabId = - request.tabId ?? latestThreadTab(request.threadId, request.agentSessionId)?.tabId; - const tab = - recordings.find((candidate) => candidate.tabId === targetTabId) ?? - (!request.tabIdExplicit && recordings.length === 1 ? recordings[0] : undefined); - if (!tab) { - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationRecordingNotActiveError", - "No recording is active for this thread.", - ); - } - return tab.control.agent(request.agentSessionId ?? "", () => stopRecording(tab)); - } - } - const tab = await requireTab(request); - // Closing must unblock an action waiting on a dialog, without queueing behind it. - if (request.operation === "close") { - if (tab.control.agentId !== request.agentSessionId) - throw new BrowserControlInterrupted( - "Only the agent session that opened this tab can close it.", - "agentMismatch", - ); - if (tab.control.controller !== null) - throw new BrowserControlInterrupted("A human controls this tab.", "humanControl"); - void tab.control.close().catch(constVoid); - await Effect.runPromise(manager.close({ threadId: tab.threadId, tabId: tab.tabId })); - dropTab(tab, false); - return {}; - } - // A click can be waiting for its dialog. Resolve it outside the serial queue, - // with the same owner check, so the operation can finish and control can drain. - if (request.operation === "dialog") { - if (tab.control.controller !== null) - throw new BrowserControlInterrupted("A human controls this tab.", "humanControl"); - await resolveDialog(tab, input as PreviewAutomationDialogInput); - return statusWithTitle(tab, request.agentSessionId); - } - const agentSessionId = request.agentSessionId!; - // A tab this session did not open, while it cannot act there (another - // agent's, or the user's while they drive): read it in place, without - // queueing behind the human's input. Its own tabs keep the takeover - // contract: a human taking control interrupts every agent call. - if ( - READ_OPERATIONS.has(request.operation) && - tab.control.agentId !== agentSessionId && - !tab.control.agentCanActNow(agentSessionId) - ) { - return tab.control.observe(async () => { - if (tab.dialog) - throw new BrowserControlInterrupted( - "A browser dialog is pending. Read preview_status.", - "dialogPending", - ); - return executeTabOperation(tab, request); - }); - } - return tab.control.agent(agentSessionId, async () => { - if (tab.dialog) - throw new BrowserControlInterrupted( - "A browser dialog is pending. Read preview_status and use preview_dialog first.", - "dialogPending", - ); - const generation = tab.control.generation; - try { - return await executeTabOperation(tab, request); - } finally { - if (generation !== tab.control.generation) ServerBrowserPage.invalidateRefs(tab.page); - } - }); - }; - - const executeTabOperation = async (tab: ServerTab, request: PreviewAutomationRequest) => { - const input = request.input; - switch (request.operation) { - case "navigate": { - const navigateInput = input as PreviewAutomationNavigateInput; - await recordAction(tab, "navigate", () => - navigate( - tab, - resolveNavigationUrl(navigateInput), - navigateInput.readiness ?? "load", - navigateInput.timeoutMs ?? request.timeoutMs, - ), - ); - return statusWithTitle(tab, request.agentSessionId); - } - case "resize": { - const setting = resolvePreviewViewport(input as PreviewAutomationResizeInput); - await Effect.runPromise( - manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }), - ); - await applySetting(tab, setting); - return { - tabId: tab.tabId, - setting, - viewport: tab.page.viewportSize() ?? UNATTACHED_FILL_VIEWPORT, - }; - } - case "setColorScheme": { - const { colorScheme } = input as PreviewAutomationSetColorSchemeInput; - const snapshot = await Effect.runPromise( - manager.adjust({ threadId: tab.threadId, tabId: tab.tabId, colorScheme }), - ); - await applyRendering(tab, snapshot); - return { tabId: tab.tabId, colorScheme }; - } - case "snapshot": { - return withScreencastsPaused(tab, () => - ServerBrowserPage.snapshot({ ...tab, renderScale: RENDER_SCALE }), - ); - } - case "click": { - const clickInput = input as PreviewAutomationClickInput; - await recordAction(tab, "click", () => - ServerBrowserPage.click(tab.page, clickInput, pointerFor(tab)), - ); - return undefined; - } - case "hover": - return recordAction(tab, "hover", () => - ServerBrowserPage.hover(tab.page, input as PreviewAutomationHoverInput, pointerFor(tab)), - ); - case "select": - return recordAction(tab, "select", () => - ServerBrowserPage.select(tab.page, input as PreviewAutomationSelectInput), - ); - case "drag": - return recordAction(tab, "drag", () => - ServerBrowserPage.drag(tab.page, input as PreviewAutomationDragInput, pointerFor(tab)), - ); - case "upload": - return recordAction(tab, "upload", () => - uploadFiles(tab, input as PreviewAutomationUploadInput), - ); - case "type": - return recordAction(tab, "type", () => - ServerBrowserPage.type(tab.page, input as PreviewAutomationTypeInput), - ); - case "press": - return recordAction(tab, "press", () => - ServerBrowserPage.press(tab.page, input as PreviewAutomationPressInput), - ); - case "scroll": - return recordAction(tab, "scroll", () => - ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput), - ); - case "evaluate": - return ServerBrowserPage.evaluate( - tab.cdp, - input as PreviewAutomationEvaluateInput, - request.timeoutMs, - ); - case "waitFor": - return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput); - case "recordingStart": { - const recording = await startRecording(tab); - return { tabId: tab.tabId, recording: true, startedAt: recording.startedAt }; - } - } - }; - - const handleRequest = (connectionId: string, request: PreviewAutomationRequest) => - Effect.tryPromise({ - try: () => runOperation(request).finally(() => markUsed(request)), - catch: ServerBrowserPage.toOperationError, - }).pipe( - Effect.match({ - onSuccess: (result) => ({ ok: true as const, result }), - onFailure: (error) => ({ - ok: false as const, - error: { - _tag: error.tag, - message: error.message, - ...(error.detail === undefined ? {} : { detail: error.detail }), - }, - }), - }), - Effect.flatMap((outcome) => - broker.respond({ - clientId: SERVER_HOST_CLIENT_ID, - connectionId, - requestId: request.requestId, - ...outcome, + const manager = yield* PreviewManager.PreviewManager; + let current: Effect.Success | undefined; + let connectionId: string | undefined; + let profiles: PreviewReportProfilesInput | undefined; + const live = yield* cacheInOwnerScope( + Effect.promise(() => import("./ServerBrowserLive.ts")).pipe( + Effect.flatMap((module) => module.make), + Effect.tap((value) => + Effect.gen(function* () { + current = value; + if (connectionId !== undefined) yield* value.onHostConnected(connectionId); + if (profiles !== undefined) yield* value.service.reportProfiles(profiles); }), ), - Effect.ignore, - ); - - const mirrorManagerEvent = (event: PreviewEvent) => - Effect.promise(async () => { - if (event.type === "opened" && event.snapshot.runtime === "server") { - await ensureTab(event.snapshot).catch(constVoid); - return; - } - const key = tabKey(event.threadId, event.tabId); - const tab = tabs.get(key); - if (event.type === "closed" && !tab && pendingTabs.has(key)) closedPendingTabs.add(key); - if (!tab) return; - if (event.type === "closed") { - closedSessions.add(key); - dropTab(tab, false); - closedSessions.delete(key); - return; - } - // Any client or agent may change these; the page follows what was published. - if (event.type === "resized") { - await tab.control - .system(async () => { - if ( - !viewportSettingsEqual(event.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, tab.setting) - ) { - await applySetting(tab, event.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT); - } - await applyRendering(tab, event.snapshot); - const request = event.request; - if (request?.clear === "cookies") await tab.cdp.send("Network.clearBrowserCookies"); - if (request?.clear === "cache") await tab.cdp.send("Network.clearBrowserCache"); - if (request?.hardReload) await tab.cdp.send("Page.reload", { ignoreCache: true }); - }) - .catch((cause: unknown) => - runFork(Effect.logWarning("server preview could not apply a tab setting", { cause })), - ); - } - }); - - const releaseViewerInput = async (viewer: ViewerState, session: CDPSession) => { - for (const { key, code } of viewer.pressedKeys.values()) { - await session.send("Input.dispatchKeyEvent", { type: "keyUp", key, code }).catch(constVoid); - } - viewer.pressedKeys.clear(); - for (const [button, point] of viewer.pressedButtons) { - await session - .send("Input.dispatchMouseEvent", { - type: "mouseReleased", - button, - ...point, - buttons: 0, - clickCount: 1, - }) - .catch(constVoid); - } - viewer.pressedButtons.clear(); - viewer.requestedSize = null; - }; - - const dispatchViewerInput = async ( - tab: ServerTab, - session: CDPSession, - viewer: ViewerState, - raw: unknown, - ) => { - const message = asRecord(raw); - if (!message) return; - viewer.inputAt = Date.now(); - const modifiers = modifiersOf(message); - switch (message.type) { - case "mouse": { - const action = message.action; - const type = - action === "down" ? "mousePressed" : action === "up" ? "mouseReleased" : "mouseMoved"; - const button = ["none", "left", "middle", "right"].includes(String(message.button)) - ? (message.button as "none" | "left" | "middle" | "right") - : "none"; - await session.send("Input.dispatchMouseEvent", { - type, - x: num(message.x), - y: num(message.y), - button, - buttons: num(message.buttons), - clickCount: num(message.clickCount, type === "mouseMoved" ? 0 : 1), - modifiers, - }); - if (button !== "none") { - if (action === "down") - viewer.pressedButtons.set(button, { x: num(message.x), y: num(message.y) }); - else if (action === "up") viewer.pressedButtons.delete(button); - } - return; - } - case "wheel": - viewer.scrolledAt = Date.now(); - await session.send("Input.dispatchMouseEvent", { - type: "mouseWheel", - x: num(message.x), - y: num(message.y), - deltaX: num(message.deltaX), - deltaY: num(message.deltaY), - modifiers, - }); - return; - case "key": { - const key = typeof message.key === "string" ? message.key : ""; - const code = typeof message.code === "string" ? message.code : ""; - const text = typeof message.text === "string" ? message.text : undefined; - if (message.action === "up") { - await session.send("Input.dispatchKeyEvent", { type: "keyUp", key, code, modifiers }); - viewer.pressedKeys.delete(code || key); - return; - } - await session.send("Input.dispatchKeyEvent", { - type: text ? "keyDown" : "rawKeyDown", - key, - code, - modifiers, - ...(text ? { text, unmodifiedText: text } : {}), - ...editingCommand(key, modifiers), - windowsVirtualKeyCode: num( - message.keyCode, - key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0, - ), - }); - viewer.pressedKeys.set(code || key, { key, code }); - return; - } - case "text": - if (typeof message.text === "string" && message.text.length > 0) { - await session.send("Input.insertText", { text: message.text.slice(0, 10_000) }); - } - return; - case "resize": { - const width = Math.min(Math.round(num(message.width)), 3840); - const height = Math.min(Math.round(num(message.height)), 2160); - if (width < 100 || height < 100) return; - viewer.requestedSize = { width, height, order: ++viewerResizeOrder }; - if (tab.setting._tag !== "fill" || tab.desktop) return; - const current = tab.page.viewportSize(); - if (current?.width === width && current.height === height) return; - await tab.page.setViewportSize({ width, height }); - broadcastViewport(tab); - return; - } - case "viewport": { - const setting = decodeViewportSetting(message.setting); - await Effect.runPromise( - manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }), - ); - await applySetting(tab, setting); - return; - } - case "navigate": - if (typeof message.url === "string") { - const url = normalizePreviewUrl(message.url); - await tab.page.goto(url, VIEWER_NAVIGATION_OPTIONS); - } - return; - case "history": - await (num(message.delta) < 0 - ? tab.page.goBack(VIEWER_NAVIGATION_OPTIONS) - : tab.page.goForward(VIEWER_NAVIGATION_OPTIONS)); - return; - case "reload": - // A hard reload fetches everything again, as Chrome's Shift+Reload does. - if (message.ignoreCache === true) { - await session.send("Page.reload", { ignoreCache: true }); - return; - } - await tab.page.reload(VIEWER_NAVIGATION_OPTIONS); - return; - case "probe": { - const x = num(message.x); - const y = num(message.y); - const result = await session.send("Runtime.evaluate", { - expression: `(${EDITABLE_AT_POINT_SCRIPT})(${x}, ${y})`, - returnByValue: true, - }); - viewer.push({ _tag: "probe", x, y, editable: result.result.value === true }); - return; - } - } - }; - - const attachViewer: ServerBrowser["Service"]["attachViewer"] = (input) => - Effect.gen(function* () { - const tab = yield* findTab(input.threadId, input.tabId); - const output = yield* Queue.make({ - capacity: VIEWER_OUTPUT_LIMIT, - strategy: "dropping", - }); - const session = yield* Effect.acquireRelease( - Effect.tryPromise({ - try: () => tab.page.context().newCDPSession(tab.page), - catch: (cause) => new ServerBrowserLaunchError({ cause }), - }), - (session) => - Effect.promise(() => - session - .send("Page.stopScreencast") - .catch(constVoid) - .then(() => session.detach()) - .catch(constVoid), - ), - ); - const quality = Math.min(100, Math.max(1, Math.round(input.quality))); - // Chromium may drop the final frame during a burst; send a still when it settles. - let framesInFlight = 0; - let mayHaveDropped = false; - let motion = false; - const recentFrames: Array = []; - let screencastParams = Promise.resolve(); - let screencastScale = 1; - const startScreencast = (scale: number) => { - screencastScale = scale; - screencastParams = screencastParams.then(async () => { - // A scaled capture is rendering; its resume starts the stream. - if (tab.capturing > 0) return; - await session.send("Page.stopScreencast").catch(constVoid); - await session - .send("Page.startScreencast", { - format: "jpeg", - quality: screencastScale < 1 ? Math.min(quality, SCREENCAST_MOTION_QUALITY) : quality, - maxWidth: Math.max(1, Math.round(input.maxWidth * screencastScale)), - maxHeight: Math.max(1, Math.round(input.maxHeight * screencastScale)), - }) - .catch(constVoid); - }); - return screencastParams; - }; - const viewer: ViewerState = { - id: NodeCrypto.randomUUID(), - canOperate: input.canOperate, - pressedKeys: new Map(), - pressedButtons: new Map(), - push: (next) => { - // Dropped frames must still release Chromium. - if (Queue.offerUnsafe(output, next)) return; - if (next._tag === "frame") runFork(next.ack); - // State a stalled viewer cannot miss replaces its backlog. It runs - // synchronously so an older replacement can never land after a newer one. - else if (next._tag === "gone" || next._tag === "control" || next._tag === "fileChooser") { - const dropped = Effect.runSyncExit(Queue.clear(output)); - if (dropped._tag === "Failure") return; - Queue.offerUnsafe(output, next); - // The controller's open picker may have been in the dropped backlog. - const chooser = next._tag === "control" ? fileChooserMessage(tab) : null; - if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); - for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); - } - }, - pause: () => { - screencastParams = screencastParams.then(() => - session.send("Page.stopScreencast").then(constVoid, constVoid), - ); - return screencastParams; - }, - resume: () => startScreencast(screencastScale), - scrolledAt: 0, - inputAt: 0, - requestedSize: null, - }; - yield* Effect.acquireRelease( - Effect.sync(() => { - tab.viewers.add(viewer); - reportLiveTabs(); - }), - () => - Effect.promise(async () => { - await tab.control.disconnect(viewer.id, () => releaseViewerInput(viewer, session)); - tab.viewers.delete(viewer); - broadcastControl(tab); - reportLiveTabs(); - }), - ); - if (input.canOperate && tab.control.agentId === null && tab.control.controller === null) { - yield* Effect.promise(() => tab.control.take(viewer.id)); - } - broadcastControl(tab); - pushFileChooser(tab); - // Full scale: a scaled capture would flash in every other viewer. - const pushStill = async () => { - const data = await withCaptureLock(tab, () => - ServerBrowserPage.captureViewport(tab.page, session, { - format: "jpeg", - quality, - scale: 1, - }), - ).catch(() => null); - if (data) - viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); - }; - let settleTimer: ReturnType | null = null; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (settleTimer !== null) clearTimeout(settleTimer); - }), - ); - let screencastStarted = false; - session.on("Page.screencastFrame", (frame) => { - screencastStarted = true; - if (framesInFlight > 0) mayHaveDropped = true; - framesInFlight += 1; - const arrivedAt = Date.now(); - recentFrames.push(arrivedAt); - while (recentFrames[0]! < arrivedAt - SCREENCAST_MOTION_WINDOW_MS) recentFrames.shift(); - if ( - !motion && - recentFrames.length >= SCREENCAST_MOTION_FRAMES && - arrivedAt - viewer.scrolledAt < SCREENCAST_MOTION_WINDOW_MS - ) { - motion = true; - void startScreencast(0.5); - } else if (motion && arrivedAt - viewer.scrolledAt > SCREENCAST_SETTLE_MS) { - // The page keeps animating after the scroll; its own frames are sharp again. - motion = false; - recentFrames.length = 0; - void startScreencast(1); - } - if (settleTimer !== null) clearTimeout(settleTimer); - settleTimer = setTimeout(() => { - settleTimer = null; - if (!motion && !mayHaveDropped) return; - mayHaveDropped = false; - if (motion) { - motion = false; - recentFrames.length = 0; - void startScreencast(1); - } - void pushStill(); - }, SCREENCAST_SETTLE_MS); - viewer.push({ - _tag: "frame", - data: Buffer.from(frame.data, "base64"), - ack: Effect.promise(() => - sleepUntil(arrivedAt + SCREENCAST_ACK_PACE_MS).then(() => { - framesInFlight -= 1; - return session - .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) - .catch(constVoid); - }), - ), - }); - }); - broadcastViewport(tab); - yield* Effect.promise(() => startScreencast(1)); - // An idle page does not repaint for a new screencast, so the viewer - // starts from a still unless a live frame beat it. - if (!screencastStarted) yield* Effect.promise(pushStill); - return { - output, - input: (raw: unknown) => - Effect.promise(async () => { - if (!viewer.canOperate) return; - const message = asRecord(raw); - if (!message) return; - try { - if (message.type === "takeControl") { - const taking = tab.control.take(viewer.id); - broadcastControl(tab); - await taking; - pushFileChooser(tab); - } else if (message.type === "releaseControl") { - const releasing = tab.control.release(viewer.id, () => - releaseViewerInput(viewer, session), - ); - broadcastControl(tab); - await releasing; - } else if ( - message.type === "dialog" && - tab.control.controller === viewer.id && - typeof message.accept === "boolean" - ) { - await resolveDialog(tab, { - accept: message.accept, - ...(typeof message.promptText === "string" - ? { promptText: message.promptText } - : {}), - }); - } else { - await tab.control.human(viewer.id, () => - dispatchViewerInput(tab, session, viewer, message), - ); - } - } catch { - // Rejected ownership cannot mutate the page; refresh the viewer's controls. - broadcastControl(tab); - } - }), - } satisfies ServerBrowserViewer; - }); - - yield* manager.events.pipe(Stream.runForEach(mirrorManagerEvent), Effect.forkScoped); - // Whoever runs the server learns the fix before anyone opens a tab. - if ( - !PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment) && - (yield* PreviewBrowserHost.sandboxBlocked) - ) { - yield* Effect.logWarning( - `This host blocks the sandbox T3's browser runs in, so browser tabs and HTML previews will not start. Run \`${setupCommand}\` once to allow it.`, - ); - } - // The desktop took its page back (closed, swapped, crashed, or devtools opened). - // The session stays; the next viewer or agent reconnects when it re-attaches. - yield* desktopChannel.detached.pipe( - Stream.runForEach((key) => - Effect.sync(() => { - const tab = tabs.get(tabKey(key.threadId, key.tabId)); - if (tab?.desktop) dropTab(tab, false); - }), ), - Effect.forkScoped, - ); - // The page came back, for example after its DevTools closed. Reconnect now - // rather than on the next viewer or agent, or the tab's URL and title stop - // reaching every client in the meantime. - yield* desktopChannel.attached.pipe( - Stream.runForEach((key) => - Effect.gen(function* () { - if (tabs.has(tabKey(key.threadId, key.tabId))) return; - const { sessions } = yield* manager.list({ threadId: ThreadId.make(key.threadId) }); - const snapshot = sessions.find( - (session) => session.tabId === key.tabId && session.runtime === "server", - ); - if (snapshot) yield* Effect.promise(() => ensureTab(snapshot).catch(constVoid)); - }).pipe(Effect.ignore), - ), - Effect.forkScoped, - ); - yield* Effect.sync(closeIdleAgentTabs).pipe( - Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)), - Effect.forkScoped, ); + // Register the host before anyone uses it; acquisition waits for its first request or tab. const environmentId = yield* environment.getEnvironmentId; const hostSession = broker .connect( { - clientId: SERVER_HOST_CLIENT_ID, + clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, environmentId, supportedOperations: [...PREVIEW_AUTOMATION_SERVER_OPERATIONS], }, @@ -2416,10 +170,11 @@ const make = Effect.gen(function* () { events.pipe( Stream.runForEach((event) => { if (event.type === "connected") { - hostConnectionId = event.connectionId; - return Effect.sync(reportLiveTabs); + connectionId = event.connectionId; + return current?.onHostConnected(event.connectionId) ?? Effect.void; } - return handleRequest(event.connectionId, event.request).pipe( + return live.pipe( + Effect.flatMap((value) => value.handleRequest(event.connectionId, event.request)), Effect.forkScoped, Effect.asVoid, ); @@ -2427,32 +182,32 @@ const make = Effect.gen(function* () { ), ), ); - // The broker disconnects timed-out hosts, including slow first installs. Reconnect. yield* hostSession.pipe( Effect.exit, - Effect.andThen(Effect.sleep(HOST_RECONNECT_DELAY)), + Effect.andThen(Effect.sleep("1 second")), Effect.forever, Effect.forkScoped, ); - yield* Effect.addFinalizer(() => - Effect.promise(async () => { - await contexts.close(); - }), + yield* manager.events.pipe( + Stream.runForEach((event) => + live.pipe(Effect.flatMap((value) => value.mirrorManagerEvent(event))), + ), + Effect.forkScoped, ); - - const clearProfile = (profileId: string) => - Effect.tryPromise({ - try: () => contexts.clearProfile(profileId), - catch: (cause) => new PreviewClearProfileError({ profileId, cause }), - }); - return ServerBrowser.of({ - attachViewer, - clearProfile, - reportProfiles, - openDownload, - answerFileChooser, + attachViewer: (input) => + live.pipe(Effect.flatMap((value) => value.service.attachViewer(input))), + answerFileChooser: (input) => + live.pipe(Effect.flatMap((value) => value.service.answerFileChooser(input))), + openDownload: (input) => + live.pipe(Effect.flatMap((value) => value.service.openDownload(input))), + clearProfile: (profileId) => + live.pipe(Effect.flatMap((value) => value.service.clearProfile(profileId))), + reportProfiles: (input) => + Effect.suspend(() => { + profiles = input; + return current?.service.reportProfiles(input) ?? Effect.void; + }), }); }); - export const layer = Layer.effect(ServerBrowser, make); diff --git a/apps/server/src/preview/ServerBrowserLive.ts b/apps/server/src/preview/ServerBrowserLive.ts new file mode 100644 index 0000000000000..31be5331721a3 --- /dev/null +++ b/apps/server/src/preview/ServerBrowserLive.ts @@ -0,0 +1,2314 @@ +import { + ServerBrowser, + ServerBrowserTabNotFoundError, + ServerBrowserLaunchError, + type ServerBrowserViewer, + type ServerBrowserViewerOutput, +} from "./ServerBrowser.ts"; +// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off - Playwright callbacks run outside the Effect runtime. +// Screencasts ignore emulated device scale; real 2x keeps captures sharp. +// --disable-gpu uses cheaper software compositing while preserving SwiftShader WebGL. +import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, + FILL_PREVIEW_VIEWPORT, + findBrowserProfile, + INCOGNITO_BROWSER_PROFILE_ID, + resolveBrowserProfiles, + type BrowserProfile, + PreviewViewportSetting as PreviewViewportSettingSchema, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + type PreviewAutomationActionEvent, + type PreviewAutomationClickInput, + type PreviewAutomationDialogInput, + type PreviewAutomationConsoleEntry, + type PreviewAutomationDragInput, + type PreviewAutomationEvaluateInput, + type PreviewAutomationHoverInput, + type PreviewAutomationNavigateInput, + type PreviewAutomationNetworkEntry, + type PreviewAutomationOpenInput, + type PreviewAutomationPressInput, + type PreviewAutomationRequest, + type PreviewAutomationResizeInput, + type PreviewAutomationScrollInput, + type PreviewAutomationSelectInput, + type PreviewAutomationSetColorSchemeInput, + type PreviewAutomationStatus, + type PreviewAutomationTypeInput, + type PreviewAutomationUploadInput, + type PreviewAutomationWaitForInput, + PreviewClearProfileError, + type PreviewEvent, + type PreviewNavStatus, + type PreviewSessionSnapshot, + type PreviewViewportSetting, + ThreadId, + SERVER_BROWSER_AUTOMATION_CLIENT_ID, + type PreviewAppearancePreference, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as Effect from "effect/Effect"; +import { constVoid } from "effect/Function"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import type { + BrowserContext, + CDPSession, + Dialog, + Download, + FileChooser, + Page, +} from "playwright-core"; + +import { PENDING_ATTACHMENT_THREAD_SEGMENT } from "../attachmentStore.ts"; +import * as ServerConfig from "../config.ts"; +import { resolveRootCliCommand } from "../cli/invocation.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts"; +import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; +import * as PreviewManager from "./Manager.ts"; +import * as ServerBrowserPage from "./ServerBrowserPage.ts"; +import * as PreviewBrowser from "./PreviewBrowser.ts"; +import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; +import { presentAsChrome, ServerBrowserContexts } from "./ServerBrowserContexts.ts"; +import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; + +const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID; +const RENDER_SCALE = 2; +// Chromium allows three unacked frames, so 100 ms pacing caps viewers near 30 fps. +const SCREENCAST_ACK_PACE_MS = 100; +const SCREENCAST_SETTLE_MS = 200; +// Only scrolling triggers reduced-quality motion frames; animations stay sharp. +const SCREENCAST_MOTION_FRAMES = 4; +const SCREENCAST_MOTION_WINDOW_MS = 300; +const SCREENCAST_MOTION_QUALITY = 50; +/** How long a new tab waits for the desktop app to mount it before running headless. */ +const DESKTOP_ATTACH_TIMEOUT = "10 seconds"; +const VIEWER_OUTPUT_LIMIT = 64; +const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; +const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); +/** The agent cursor glides to its target, then pulses just before the press, like desktop tabs. */ +const AGENT_CURSOR_MOVE_MS = 160; +const AGENT_CURSOR_CLICK_LEAD_MS = 40; + +const sleepUntil = (deadline: number) => + new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); + +// Touch viewers raise the keyboard for editable targets, including opaque frames. +const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { + let element = document.elementFromPoint(x, y); + while (element && element.shadowRoot) { + const inner = element.shadowRoot.elementFromPoint(x, y); + if (!inner || inner === element) break; + element = inner; + } + if (element && element.tagName === "LABEL" && element.control) element = element.control; + if (!element) return false; + if (element.tagName === "IFRAME" || element.tagName === "FRAME") return true; + if (element.isContentEditable) return true; + if (element.tagName === "TEXTAREA") return !element.disabled && !element.readOnly; + if (element.tagName !== "INPUT") return false; + const nonText = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]; + return !nonText.includes(element.type) && !element.disabled && !element.readOnly; +}`; +const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const; +const NAVIGATION_TIMEOUT_MS = 15_000; +const VIEWER_NAVIGATION_OPTIONS = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const; +const ACTION_TIMELINE_LIMIT = 50; + +const isTabNotFound = Schema.is(ServerBrowserTabNotFoundError); +const isHostSetupError = Schema.is( + Schema.Union([ + PreviewBrowserHost.PreviewBrowserSandboxError, + PreviewBrowserHost.PreviewBrowserLibrariesError, + ]), +); + +interface ViewerState { + readonly id: string; + readonly canOperate: boolean; + readonly pressedKeys: Map; + readonly pressedButtons: Map<"left" | "middle" | "right", { x: number; y: number }>; + readonly push: (output: ServerBrowserViewerOutput) => void; + readonly pause: () => Promise; + readonly resume: () => Promise; + scrolledAt: number; + /** Last input from this viewer; page copies reach its clipboard only right after. */ + inputAt: number; + /** Panel bounds, retained in fixed mode; passive viewers never request a size. */ + requestedSize: { width: number; height: number; order: number } | null; +} + +interface EncoderWindow { + __t3Recorder: { + cursor(x: number, y: number, click: boolean): void; + frame(data: string, width: number): Promise; + stop(): Promise<{ mimeType: string | null; count: number; bytes: number }>; + chunk(index: number): Promise; + }; +} + +interface Recording { + readonly encoder: Page; + readonly session: CDPSession; + readonly startedAt: string; + /** Frames still being handed to the encoder; stopping waits for them. */ + readonly framesInFlight: Set>; +} + +interface ServerTab { + readonly threadId: ThreadId; + readonly tabId: string; + readonly page: Page; + readonly cdp: CDPSession; + readonly createdAt: number; + /** The agent's latest request on this tab; idle agent tabs close. */ + usedAt: number; + readonly viewers: Set; + readonly consoleEntries: Array; + readonly networkEntries: Array; + readonly actionTimeline: Array; + readonly control: SessionControl; + /** The tab's own storage context, closed with it. Popups share their opener's. */ + readonly isolatedContext: boolean; + /** + * Set when the desktop app renders this tab. The server drives the desktop's + * page; the desktop owns its size, storage, and window. + */ + readonly desktop: { readonly close: () => Promise } | null; + readonly profileId: string | undefined; + /** Set when a page in another tab opened this one with `window.open` or a link. */ + readonly openerTabId: string | undefined; + /** Finished downloads, newest last; files live until the tab closes. */ + readonly downloads: Array; + /** A page's open file picker, waiting for the controlling viewer's files. */ + fileChooser: { + readonly id: string; + readonly chooser: FileChooser; + readonly accept: string; + } | null; + dialog: Dialog | null; + setting: PreviewViewportSetting; + colorScheme: PreviewAppearancePreference; + zoomFactor: number; + loading: boolean; + closing: boolean; + recording: Recording | null; + initialNavigation: Promise | null; + /** Counts main-frame navigation requests, so late events can tell they were superseded. */ + navigationGeneration: number; + /** + * The latest main-frame navigation, when it aborted, to tell a navigation's + * download from a page's. The timer settles it if no download follows. + */ + abortedNavigation: { + readonly url: string; + readonly generation: number; + readonly timer: ReturnType; + } | null; + /** The latest queued start, so a stop can find a recording still starting. */ + recordingStart: Promise | null; + /** Serializes captures and recording start/stop. */ + captureLock: Promise; + /** Scaled captures rendering now; screencasts stay stopped meanwhile. */ + capturing: number; +} + +interface ServerDownload { + readonly id: string; + readonly fileName: string; + readonly path: string; + readonly sizeBytes: number; + readonly url: string; + readonly completedAt: string; +} + +/** One agent session and the whole server; each tab holds a renderer process. */ +/** + * Operations an agent may run on any tab in its thread, even while a human + * drives it. Evaluate is not one: page script can change anything, so it needs + * the same control as clicking. + */ +const READ_OPERATIONS: ReadonlySet = new Set([ + "status", + "snapshot", + "waitFor", +]); + +const AGENT_TAB_LIMIT = 8; +const SERVER_TAB_LIMIT = 32; +/** Agent tabs nobody watches close after this long without an agent request. */ +const AGENT_TAB_IDLE_MS = 30 * 60 * 1000; +const IDLE_SWEEP_INTERVAL = "1 minute"; + +const DOWNLOAD_LIMIT = 20; +/** How long an aborted navigation waits for the download that usually explains it. */ +const ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS = 5_000; +const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024; + +const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`; + +const pushBounded = ( + buffer: Array, + entry: A, + limit = ServerBrowserPage.DIAGNOSTIC_BUFFER_LIMIT, +) => { + buffer.push(entry); + if (buffer.length > limit) buffer.splice(0, buffer.length - limit); +}; + +const viewportSettingsEqual = (left: PreviewViewportSetting, right: PreviewViewportSetting) => + left._tag === right._tag && + (left._tag === "fill" || + (right._tag !== "fill" && left.width === right.width && left.height === right.height)); + +const fixedViewportSize = (setting: PreviewViewportSetting) => + setting._tag === "fill" ? null : { width: setting.width, height: setting.height }; + +const asRecord = (value: unknown): Record | null => + typeof value === "object" && value !== null ? (value as Record) : null; + +const num = (value: unknown, fallback = 0) => + typeof value === "number" && Number.isFinite(value) ? value : fallback; + +const modifiersOf = (message: Record) => { + const value = num(message.modifiers); + return Number.isInteger(value) && value >= 0 && value < 16 ? value : 0; +}; + +// Cmd shortcuts from Apple viewers are no editing shortcut for Linux or headless +// Chromium, so they carry the command. Ctrl already works natively on Linux, +// except copy and cut, which headless Chromium only runs as commands. +const editingCommand = (key: string, modifiers: number) => { + const modifier = modifiers & 0b0111; + if (modifier !== 2 && modifier !== 4) return null; + const lower = key.toLowerCase(); + const command = + lower === "c" + ? "copy" + : lower === "x" + ? "cut" + : modifier !== 4 + ? null + : lower === "a" + ? "selectAll" + : lower === "z" + ? modifiers & 8 + ? "redo" + : "undo" + : null; + return command ? { commands: [command] } : null; +}; + +const CLIPBOARD_TEXT_LIMIT = 1024 * 1024; +/** Copies reach the viewer only this soon after it last touched the page. */ +const CLIPBOARD_GESTURE_MS = 5_000; +const CLIPBOARD_BINDING = "__t3PreviewClipboard"; +// Headless Chromium shares one clipboard between every context, so pages +// report their own copies instead of the clipboard being read back. +const CLIPBOARD_SCRIPT = `(() => { + const send = (text) => { + if (typeof text !== "string" || text.length === 0) return; + try { globalThis.${CLIPBOARD_BINDING}?.(text.slice(0, ${CLIPBOARD_TEXT_LIMIT})); } catch {} + }; + const selection = () => { + const field = document.activeElement; + if ((field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) && field.selectionStart !== null) + return field.value.slice(field.selectionStart, field.selectionEnd ?? field.selectionStart); + return String(document.getSelection() ?? ""); + }; + // Bubbling to window runs after page handlers, before cut removes the selection. + const copied = (event) => + send(event.defaultPrevented ? event.clipboardData?.getData("text/plain") : selection()); + addEventListener("copy", copied); + addEventListener("cut", copied); + const proto = globalThis.Clipboard?.prototype; + if (!proto) return; + const writeText = proto.writeText; + proto.writeText = function (text) { + const result = writeText.call(this, text); + result.then(() => send(String(text)), () => {}); + return result; + }; + const write = proto.write; + proto.write = function (items) { + const result = write.call(this, items); + result.then(async () => { + const item = [...items].find((candidate) => candidate.types.includes("text/plain")); + if (item) send(await (await item.getType("text/plain")).text()); + }, () => {}); + return result; + }; +})();`; + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const host = { platform: yield* HostProcess.Platform, arch: yield* HostProcess.Architecture }; + const manager = yield* PreviewManager.PreviewManager; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const environment = yield* ServerEnvironment.ServerEnvironment; + const previewBrowser = yield* PreviewBrowser.PreviewBrowser; + const desktopChannel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + const runFork = Effect.runForkWith(yield* Effect.context()); + const launchServices = yield* Effect.context(); + // The fix every host error names, rendered for how this server was launched. + const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); + + const tabs = new Map(); + const pendingTabs = new Map>(); + /** Sessions closed while their tab was still opening; the open discards its page. */ + const closedPendingTabs = new Set(); + /** Popup pages waiting for the tab their `opened` event creates. */ + const adoptedPages = new Map(); + /** Sessions the manager closed, so their tabs end for good. Pruned once dropped. */ + const closedSessions = new Set(); + let hostConnectionId: string | null = null; + let viewerResizeOrder = 0; + + const contexts = new ServerBrowserContexts({ + profilesDir: NodePath.join(config.stateDir, "server-browser", "profiles"), + executable: () => Effect.runPromise(previewBrowser.executable), + // Playwright's launch error drops Chrome's own output; its sandbox note survives. + diagnose: (executable, cause) => + Effect.runPromiseWith(launchServices)( + PreviewBrowserHost.diagnoseLaunchFailure({ + executable, + setupCommand, + output: /sandboxing failed/i.test(String(cause)) + ? PreviewBrowserHost.NO_SANDBOX_SIGNATURE + : "", + }), + ), + onContextClose: (context) => { + for (const tab of tabs.values()) { + if (tab.page.context() === context) dropTab(tab, true); + } + }, + }); + + const dialogStatus = (tab: ServerTab) => + tab.dialog + ? { + type: tab.dialog.type(), + message: tab.dialog.message().slice(0, 4000), + defaultValue: tab.dialog.defaultValue().slice(0, 4000), + } + : null; + + const broadcastControl = (tab: ServerTab) => { + for (const viewer of tab.viewers) + viewer.push({ + _tag: "control", + canOperate: viewer.canOperate, + controller: + tab.control.controller === viewer.id + ? "you" + : tab.control.controller !== null + ? "another-viewer" + : tab.control.agentId !== null + ? "agent" + : "unclaimed", + generation: tab.control.generation, + dialog: dialogStatus(tab), + }); + }; + + const resolveDialog = async (tab: ServerTab, input: PreviewAutomationDialogInput) => { + const dialog = tab.dialog; + if (!dialog) throw new Error("No dialog is pending."); + if (input.accept) await dialog.accept(input.promptText); + else await dialog.dismiss(); + if (tab.dialog === dialog) tab.dialog = null; + ServerBrowserPage.invalidateRefs(tab.page); + broadcastControl(tab); + }; + + const report = (tab: ServerTab, navStatus: PreviewNavStatus) => { + void tab.cdp + .send("Page.getNavigationHistory") + .catch(() => null) + .then((history) => { + const index = history?.currentIndex ?? 0; + const count = history?.entries.length ?? 0; + runFork( + manager + .reportStatus({ + threadId: tab.threadId, + tabId: tab.tabId, + serverControlled: true, + navStatus, + canGoBack: index > 0, + canGoForward: index < count - 1, + }) + .pipe(Effect.ignore), + ); + }); + }; + + const reportLoaded = async (tab: ServerTab) => { + const url = tab.page.url(); + // Chromium's error page loads after `requestfailed` and must not clear LoadFailed. + if (url === "about:blank" || url.startsWith("chrome-error://")) return; + const title = (await tab.page.title().catch(() => "")).slice(0, 512); + // A navigation that started while reading the title owns the status now. + if (tab.loading || tab.page.url() !== url) return; + report(tab, { _tag: "Success", url: url.slice(0, 2048), title }); + }; + + const reportLiveTabs = () => { + const connectionId = hostConnectionId; + if (connectionId === null) return; + runFork( + environment.getEnvironmentId.pipe( + Effect.flatMap((environmentId) => + broker.focusHost({ + clientId: SERVER_HOST_CLIENT_ID, + environmentId, + connectionId, + focused: true, + liveTabs: [...tabs.values()].map((tab) => ({ + threadId: tab.threadId, + tabId: tab.tabId, + visible: tab.viewers.size > 0, + })), + }), + ), + ), + ); + }; + + /** The CSS size the page lays out in: the viewport shrunk by the tab's zoom, as Chrome zooms. */ + const layoutSize = (tab: ServerTab) => { + const size = tab.page.viewportSize(); + if (!size || tab.desktop) return size; + return { + width: Math.max(1, Math.round(size.width / tab.zoomFactor)), + height: Math.max(1, Math.round(size.height / tab.zoomFactor)), + }; + }; + + /** + * Zooms a headless page as Chrome's zoom does: it lays out in fewer CSS + * pixels and draws each one larger, so frames keep their size. The desktop + * zooms the pages it renders itself. + */ + const applyZoom = async (tab: ServerTab) => { + if (tab.desktop) return; + const size = tab.page.viewportSize(); + if (!size) return; + if (tab.zoomFactor === 1) { + // Playwright's own override carries the plain viewport. + await tab.page.setViewportSize(size); + return; + } + const layout = layoutSize(tab)!; + await tab.cdp.send("Emulation.setDeviceMetricsOverride", { + width: layout.width, + height: layout.height, + deviceScaleFactor: RENDER_SCALE * tab.zoomFactor, + mobile: false, + }); + }; + + const broadcastViewport = (tab: ServerTab) => { + const size = layoutSize(tab); + if (!size) return; + for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size }); + }; + + const applySetting = async (tab: ServerTab, setting: PreviewViewportSetting) => { + tab.setting = setting; + // The desktop lays its webview out at the published setting itself. + if (tab.desktop) return; + const size = + fixedViewportSize(setting) ?? + [...tab.viewers] + .map((viewer) => viewer.requestedSize) + .filter((requested) => requested !== null) + .sort((left, right) => right.order - left.order)[0] ?? + UNATTACHED_FILL_VIEWPORT; + await tab.page.setViewportSize({ width: size.width, height: size.height }); + await applyZoom(tab); + broadcastViewport(tab); + }; + + /** Applies a tab's published appearance and, for headless tabs, zoom. */ + const applyRendering = async (tab: ServerTab, snapshot: PreviewSessionSnapshot) => { + const colorScheme = snapshot.colorScheme ?? "system"; + if (colorScheme !== tab.colorScheme) { + tab.colorScheme = colorScheme; + await tab.page.emulateMedia({ colorScheme: colorScheme === "system" ? null : colorScheme }); + } + const zoomFactor = snapshot.zoomFactor ?? 1; + if (zoomFactor !== tab.zoomFactor && !tab.desktop) { + tab.zoomFactor = zoomFactor; + await applyZoom(tab); + broadcastViewport(tab); + } + }; + + /** Whether the preview session for a tab still exists. */ + const sessionOpen = (tab: ServerTab) => !closedSessions.has(tabKey(tab.threadId, tab.tabId)); + + const dropTab = (tab: ServerTab, closeSession: boolean) => { + const key = tabKey(tab.threadId, tab.tabId); + if (tabs.get(key) !== tab) return; + tabs.delete(key); + tab.closing = true; + clearAbortedNavigation(tab); + // A desktop page outlives the connection unless its session closed with it. + const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; + for (const viewer of tab.viewers) viewer.push({ _tag: end }); + void tab.control.close().catch(constVoid); + // The desktop owns its page; letting go only ends this connection. + if (tab.desktop) void tab.desktop.close().catch(constVoid); + else void tab.page.close().catch(constVoid); + if (tab.isolatedContext) void tab.page.context().close().catch(constVoid); + void tab.recording?.encoder.close().catch(constVoid); + void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid); + reportLiveTabs(); + if (closeSession) { + runFork(manager.close({ threadId: tab.threadId, tabId: tab.tabId }).pipe(Effect.ignore)); + } + }; + + /** + * With a desktop app attached, every tab of this server renders there, so a + * new tab waits for its `` instead of launching headless. + */ + const desktopRenders = (snapshot: PreviewSessionSnapshot) => + desktopChannel.available + ? Effect.runPromise( + desktopChannel.awaitAttached( + { threadId: snapshot.threadId, tabId: snapshot.tabId }, + DESKTOP_ATTACH_TIMEOUT, + ), + ) + : Promise.resolve(false); + + /** Connects to the desktop's page for a tab, which it renders and the server drives. */ + const connectDesktop = async (snapshot: PreviewSessionSnapshot) => { + const scope = await Effect.runPromise(Scope.make()); + try { + const endpoint = await Effect.runPromise( + desktopChannel + .endpoint({ threadId: snapshot.threadId, tabId: snapshot.tabId }) + .pipe(Scope.provide(scope)), + ); + const connected = await contexts.connectDesktopPage(endpoint); + return { + page: connected.page, + close: async () => { + await connected.browser.close().catch(constVoid); + await Effect.runPromise(Scope.close(scope, Exit.void)); + }, + }; + } catch (cause) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + throw cause; + } + }; + + const createTab = async (snapshot: PreviewSessionSnapshot): Promise => { + const adopted = adoptedPages.get(tabKey(snapshot.threadId, snapshot.tabId)); + adoptedPages.delete(tabKey(snapshot.threadId, snapshot.tabId)); + const desktop = + adopted === undefined && (await desktopRenders(snapshot)) + ? await connectDesktop(snapshot) + : null; + // An agent tab without a profile (no client reported one) keeps throwaway storage. + const isolatedContext = + adopted === undefined && + desktop === null && + ((snapshot.automationOwner !== undefined && snapshot.profileId === undefined) || + snapshot.profileId === INCOGNITO_BROWSER_PROFILE_ID); + const context = + adopted?.page.context() ?? + desktop?.page.context() ?? + (await contexts.contextFor( + snapshot.profileId ?? "default", + isolatedContext ? tabKey(snapshot.threadId, snapshot.tabId) : undefined, + )); + if (adopted?.page.isClosed()) throw new Error("The popup closed before it opened."); + // The desktop page already has its own clipboard; the bridge script is for headless tabs. + if (!desktop) await prepareContext(context); + const page = adopted?.page ?? desktop?.page ?? (await context.newPage()); + const cdp = await context.newCDPSession(page); + // The desktop's page is already a normal browser. A popup's first request + // went out before it became a tab; everything after presents as Chrome. + if (!desktop) await presentAsChrome(cdp, host); + page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS); + page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS); + const control = new SessionControl(snapshot.automationOwner ?? null, () => + ServerBrowserPage.invalidateRefs(page), + ); + const tab: ServerTab = { + threadId: ThreadId.make(snapshot.threadId), + tabId: snapshot.tabId, + page, + cdp, + createdAt: Date.now(), + usedAt: Date.now(), + viewers: new Set(), + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + control, + isolatedContext, + desktop: desktop === null ? null : { close: desktop.close }, + profileId: snapshot.profileId, + openerTabId: adopted?.openerTabId, + downloads: [], + fileChooser: null, + dialog: null, + setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, + colorScheme: "system", + zoomFactor: 1, + loading: false, + closing: false, + recording: null, + recordingStart: null, + initialNavigation: null, + navigationGeneration: 0, + abortedNavigation: null, + captureLock: Promise.resolve(), + capturing: 0, + }; + if (!desktop) { + await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT); + } + await applyRendering(tab, snapshot); + const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => + request.isNavigationRequest() && request.frame() === page.mainFrame(); + const navigationGenerations = new WeakMap(); + page.on("request", (request) => { + if (!isMainNavigation(request)) return; + navigationGenerations.set(request, ++tab.navigationGeneration); + clearAbortedNavigation(tab); + tab.loading = true; + report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" }); + }); + page.on("load", () => { + tab.loading = false; + void reportLoaded(tab); + }); + page.on("framenavigated", (frame) => { + // Same-document navigations (SPA routes) fire no load event. + if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab); + }); + page.on("requestfailed", (request) => { + const errorText = request.failure()?.errorText ?? ""; + pushBounded(tab.networkEntries, { + url: request.url(), + method: request.method(), + status: null, + failed: true, + errorText, + timestamp: new Date().toISOString(), + }); + if (!isMainNavigation(request)) return; + // A navigation a newer one replaced leaves the status to the newer one. A + // popup's first request can predate the listener, so it is untracked and + // superseded by any tracked one. + const generation = navigationGenerations.get(request) ?? 0; + if (generation !== tab.navigationGeneration) return; + // An aborted navigation is usually a download; its `download` event settles the status. + if (errorText.includes("ERR_ABORTED")) { + const url = request.url(); + const navigationGeneration = tab.navigationGeneration; + clearAbortedNavigation(tab); + tab.abortedNavigation = { + url, + generation: navigationGeneration, + timer: setTimeout(() => { + if (tab.abortedNavigation?.generation !== navigationGeneration) return; + tab.abortedNavigation = null; + if (!tab.closing) settleAbortedNavigation(tab, navigationGeneration, url, undefined); + }, ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS), + }; + return; + } + tab.loading = false; + const { code, description } = ServerBrowserPage.parseNetError(errorText); + report(tab, { + _tag: "LoadFailed", + url: request.url().slice(0, 2048), + title: "", + code, + description, + }); + }); + page.on("response", (response) => { + pushBounded(tab.networkEntries, { + url: response.url(), + method: response.request().method(), + status: response.status(), + failed: false, + timestamp: new Date().toISOString(), + }); + }); + page.on("console", (message) => { + pushBounded(tab.consoleEntries, { + level: message.type(), + text: message.text().slice(0, 2_000), + timestamp: new Date().toISOString(), + }); + }); + page.on("dialog", (dialog) => { + tab.dialog = dialog; + broadcastControl(tab); + }); + page.on("download", (download) => void saveDownload(tab, download)); + page.on("filechooser", (chooser) => void offerFileChooser(tab, chooser)); + // Popups become tabs and keep `window.opener`, so sign-in popups can report back. + page.on("popup", (popup) => void adoptPopup(tab, popup)); + // Playwright cannot reload a crashed page, so it must leave the tab list. + const key = tabKey(tab.threadId, tab.tabId); + if (closedPendingTabs.delete(key)) { + await control.close().catch(constVoid); + if (desktop) await desktop.close().catch(constVoid); + else await page.close().catch(constVoid); + if (isolatedContext) await context.close().catch(constVoid); + throw new ServerBrowserTabNotFoundError({ threadId: tab.threadId, tabId: tab.tabId }); + } + page.on("close", () => dropTab(tab, true)); + page.on("crash", () => dropTab(tab, true)); + tabs.set(key, tab); + reportLiveTabs(); + // A popup is already loading its own URL, and the desktop loads its tab's. + if (!adopted && !desktop && snapshot.navStatus._tag === "Loading") { + tab.initialNavigation = page + .goto(snapshot.navStatus.url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS }) + .then(constVoid); + // Background creation keeps the failed tab; automation awaits the original error. + void tab.initialNavigation.catch(constVoid); + } + return tab; + }; + + const downloadsRoot = NodePath.join(config.stateDir, "server-browser", "downloads"); + // Tab ids are opaque server strings; hashing keeps them out of the path. + const downloadDir = (tab: ServerTab) => + NodePath.join( + downloadsRoot, + NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"), + ); + + const clearAbortedNavigation = (tab: ServerTab) => { + if (tab.abortedNavigation !== null) clearTimeout(tab.abortedNavigation.timer); + tab.abortedNavigation = null; + }; + + /** + * Settles the status of a navigation that aborted, usually into a download. + * A tab opened straight to the file has no page to show, so it reports the + * file with its download; a page that linked to it keeps showing itself. + */ + const settleAbortedNavigation = ( + tab: ServerTab, + generation: number, + url: string, + offered: { readonly id: string; readonly fileName: string } | undefined, + ) => { + // A newer navigation owns the status. + if (tab.navigationGeneration !== generation) return; + tab.loading = false; + if (tab.page.url() !== "about:blank") { + void reportLoaded(tab); + return; + } + report(tab, { + _tag: "LoadFailed", + url: url.slice(0, 2048), + title: "", + code: -3, + description: "ERR_ABORTED", + ...(offered === undefined ? {} : { download: offered }), + }); + }; + + const saveDownload = async (tab: ServerTab, download: Download) => { + const id = NodeCrypto.randomUUID(); + const path = NodePath.join(downloadDir(tab), id); + const navigation = + download.url() === tab.abortedNavigation?.url ? tab.abortedNavigation.generation : null; + if (navigation !== null) clearAbortedNavigation(tab); + let offered: { readonly id: string; readonly fileName: string } | undefined; + try { + if (await download.failure()) return; + await NodeFSP.mkdir(downloadDir(tab), { recursive: true }); + await download.saveAs(path); + const { size } = await NodeFSP.stat(path); + if (tab.closing || size > DOWNLOAD_MAX_BYTES) { + await NodeFSP.rm(path, { force: true }); + return; + } + const saved: ServerDownload = { + id, + fileName: download.suggestedFilename().slice(0, 255) || "download", + path, + sizeBytes: size, + url: download.url().slice(0, 2048), + completedAt: new Date().toISOString(), + }; + tab.downloads.push(saved); + for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) { + await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid); + } + offered = { id, fileName: saved.fileName }; + // A tab whose own address was the file shows it in place (see + // settleAbortedNavigation), unless it navigated on while the file saved. + const shownInTab = + navigation === tab.navigationGeneration && tab.page.url() === "about:blank"; + if (shownInTab) return; + // Only the person driving the page gets the file offered; agents read it from status. + const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); + controller?.push({ + _tag: "download", + id, + fileName: saved.fileName, + sizeBytes: saved.sizeBytes, + }); + } catch (cause) { + await NodeFSP.rm(path, { force: true }).catch(constVoid); + runFork(Effect.logWarning("server preview download failed", { cause })); + } finally { + if (navigation !== null && !tab.closing) { + settleAbortedNavigation(tab, navigation, download.url(), offered); + } + } + }; + + const fileChooserMessage = (tab: ServerTab): ServerBrowserViewerOutput | null => + tab.fileChooser + ? { + _tag: "fileChooser", + id: tab.fileChooser.id, + multiple: tab.fileChooser.chooser.isMultiple(), + accept: tab.fileChooser.accept, + } + : null; + + const offerFileChooser = async (tab: ServerTab, chooser: FileChooser) => { + const previous = tab.fileChooser; + const accept = + (await chooser + .element() + .getAttribute("accept") + .catch(() => null)) ?? ""; + // A newer picker replaces an unanswered one, as a real browser allows only one. + if (previous) closeFileChooser(tab); + tab.fileChooser = { id: NodeCrypto.randomUUID(), chooser, accept: accept.slice(0, 1024) }; + pushFileChooser(tab); + }; + + const pushFileChooser = (tab: ServerTab) => { + const message = fileChooserMessage(tab); + const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); + if (message) controller?.push(message); + }; + + const closeFileChooser = (tab: ServerTab) => { + const open = tab.fileChooser; + if (!open) return; + tab.fileChooser = null; + for (const viewer of tab.viewers) viewer.push({ _tag: "fileChooserClosed", id: open.id }); + }; + + /** Hands uploaded files to the page's open picker; an empty list cancels it. */ + const setChooserFiles = async ( + input: Parameters[0], + ) => { + const tab = tabs.get(tabKey(input.threadId, input.tabId)); + const open = tab?.fileChooser; + if (!tab || !open || open.id !== input.chooserId) return false; + if (input.files.length > 0) { + await open.chooser.setFiles( + open.chooser.isMultiple() ? [...input.files] : input.files.slice(0, 1), + ); + } + if (tab.fileChooser === open) closeFileChooser(tab); + return true; + }; + /** The agent's files go to a named file input, or else to the page's open picker. */ + const uploadFiles = async (tab: ServerTab, input: PreviewAutomationUploadInput) => { + const relative = input.paths.find((path) => !NodePath.isAbsolute(path)); + if (relative !== undefined) + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationExecutionError", + `Upload paths must be absolute: ${relative}`, + ); + if (await ServerBrowserPage.setInputFiles(tab.page, input)) return undefined; + const open = tab.fileChooser; + if (!open) + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationExecutionError", + "No file picker is open. Click the page's upload control first, or pass the file input's locator.", + ); + if (input.paths.length > 1 && !open.chooser.isMultiple()) + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationExecutionError", + "This file picker accepts one file.", + ); + if (input.paths.length > 0) + await open.chooser.setFiles([...input.paths], { + timeout: input.timeoutMs ?? NAVIGATION_TIMEOUT_MS, + }); + if (tab.fileChooser === open) closeFileChooser(tab); + return undefined; + }; + + const answerFileChooser: ServerBrowser["Service"]["answerFileChooser"] = (input) => + Effect.promise(() => setChooserFiles(input).catch(() => false)); + + const openDownload: ServerBrowser["Service"]["openDownload"] = (input) => + Effect.sync(() => { + const download = tabs + .get(tabKey(input.threadId, input.tabId)) + ?.downloads.find((candidate) => candidate.id === input.downloadId); + return download === undefined + ? Option.none() + : Option.some({ path: download.path, fileName: download.fileName }); + }); + + const preparedContexts = new WeakSet(); + const prepareContext = async (context: BrowserContext) => { + if (preparedContexts.has(context)) return; + preparedContexts.add(context); + await context.grantPermissions(["clipboard-write"]); + await context.exposeBinding(CLIPBOARD_BINDING, ({ page }, text: unknown) => { + if (typeof text !== "string") return; + const tab = [...tabs.values()].find((candidate) => candidate.page === page); + const controller = tab + ? [...tab.viewers].find((viewer) => viewer.id === tab.control.controller) + : undefined; + if (!controller || Date.now() - controller.inputAt > CLIPBOARD_GESTURE_MS) return; + controller.push({ _tag: "clipboard", text: text.slice(0, CLIPBOARD_TEXT_LIMIT) }); + }); + await context.addInitScript(CLIPBOARD_SCRIPT); + }; + + const adoptPopup = async (opener: ServerTab, popup: Page) => { + const agentId = opener.control.agentId; + // A popup past an agent's limit closes; its page sees window.open return a closed window. + if (opener.closing || (agentId !== null && atTabLimit(agentId))) { + await popup.close().catch(constVoid); + return; + } + const url = popup.url(); + // Captured now: the person whose click opened the popup gets switched to it. + const controller = [...opener.viewers].find( + (viewer) => viewer.id === opener.control.controller, + ); + await Effect.runPromise( + manager.open({ + threadId: opener.threadId, + ...(/^https?:/i.test(url) ? { url } : {}), + runtime: "server", + ...(opener.profileId === undefined ? {} : { profileId: opener.profileId }), + // Agent popups stay with the agent and only float when it asks, like its own opens. + ...(opener.control.agentId === null + ? {} + : { automationOwner: opener.control.agentId, reveal: false }), + beforePublish: (snapshot) => + adoptedPages.set(tabKey(snapshot.threadId, snapshot.tabId), { + page: popup, + openerTabId: opener.tabId, + }), + }), + ).then( + (snapshot) => { + // The opener stays open behind it, so `window.opener` can still report back. + if (opener.control.agentId === null) + controller?.push({ _tag: "popup", tabId: snapshot.tabId }); + }, + async () => { + await popup.close().catch(constVoid); + }, + ); + }; + + const ensureTab = (snapshot: PreviewSessionSnapshot): Promise => { + const key = tabKey(snapshot.threadId, snapshot.tabId); + const pending = pendingTabs.get(key); + if (pending) return pending; + const existing = tabs.get(key); + if (existing) return Promise.resolve(existing); + const opening = createTab(snapshot) + .catch((cause: unknown) => { + runFork( + Effect.logWarning( + isHostSetupError(cause) ? cause.message : "server preview tab failed to start", + { cause }, + ), + ); + throw cause; + }) + .finally(() => { + pendingTabs.delete(key); + closedPendingTabs.delete(key); + }); + pendingTabs.set(key, opening); + return opening; + }; + + const findTab = (threadId: string, tabId: string) => + Effect.gen(function* () { + const existing = tabs.get(tabKey(threadId, tabId)); + if (existing) return existing; + const { sessions } = yield* manager.list({ threadId: ThreadId.make(threadId) }); + const snapshot = sessions.find( + (session) => session.tabId === tabId && session.runtime === "server", + ); + if (!snapshot) return yield* new ServerBrowserTabNotFoundError({ threadId, tabId }); + return yield* Effect.tryPromise({ + try: () => ensureTab(snapshot), + catch: (cause) => (isTabNotFound(cause) ? cause : new ServerBrowserLaunchError({ cause })), + }); + }); + + const atTabLimit = (agentSessionId: string) => + tabs.size + pendingTabs.size >= SERVER_TAB_LIMIT || + [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length >= + AGENT_TAB_LIMIT; + + const assertTabCapacity = (agentSessionId: string) => { + if (atTabLimit(agentSessionId)) + throw new BrowserControlInterrupted( + `Too many server browser tabs are open (${AGENT_TAB_LIMIT} per agent session, ${SERVER_TAB_LIMIT} per server). Close one with t3_preview_close or reuse one from preview_status tabs.`, + "tabLimit", + ); + }; + + /** Closes agent tabs that no one watches and the agent stopped using. */ + const closeIdleAgentTabs = () => { + const cutoff = Date.now() - AGENT_TAB_IDLE_MS; + for (const tab of tabs.values()) { + if (tab.control.agentId !== null && tab.viewers.size === 0 && tab.usedAt < cutoff) + dropTab(tab, true); + } + }; + + /** + * The tab a request without a tabId means: the caller's newest tab, else the + * thread's tab the user is looking at or used last, so "this page" works. + */ + const latestThreadTab = (threadId: string, agentSessionId?: string) => { + const threadTabs = [...tabs.values()].filter((tab) => tab.threadId === threadId); + return ( + threadTabs + .filter((tab) => tab.control.agentId === agentSessionId) + .sort((left, right) => right.createdAt - left.createdAt)[0] ?? + threadTabs.sort( + (left, right) => + Number(right.viewers.size > 0) - Number(left.viewers.size > 0) || + right.usedAt - left.usedAt, + )[0] + ); + }; + + const tabOwner = (tab: ServerTab) => + tab.control.controller !== null + ? ("human" as const) + : tab.control.agentId !== null + ? ("agent" as const) + : ("unclaimed" as const); + + /** The latest profile list a client reported; agents choose from it. */ + let reportedProfiles: { + readonly profiles: ReadonlyArray; + readonly defaultProfileId: string; + } | null = null; + + const reportProfiles: ServerBrowser["Service"]["reportProfiles"] = (input) => + Effect.sync(() => { + const profiles = resolveBrowserProfiles(input.profiles); + reportedProfiles = { + profiles, + defaultProfileId: + findBrowserProfile(profiles, input.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID, + }; + }); + + const profileStatus = () => { + const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES; + return { + profiles: profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + ...(profile.kind === "incognito" ? { incognito: true } : {}), + })), + ...(reportedProfiles ? { defaultProfileId: reportedProfiles.defaultProfileId } : {}), + }; + }; + + /** Matches an id, then a case-insensitive name. Omitted means the reported default. */ + const resolveOpenProfile = (requested: string | undefined): string | undefined => { + if (requested === undefined) return reportedProfiles?.defaultProfileId; + const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES; + const wanted = requested.toLowerCase(); + const match = + findBrowserProfile(profiles, requested) ?? + profiles.find((profile) => profile.name.toLowerCase() === wanted); + if (match) return match.id; + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationUnknownProfileError", + `No browser profile is named "${requested}". Use one of: ${profiles + .map((profile) => `${profile.name} (id ${profile.id})`) + .join(", ")}.`, + ); + }; + + const statusWithTitle = async ( + tab: ServerTab | undefined, + agentSessionId?: string, + ): Promise => { + if (!tab) { + return { + available: false, + visible: false, + tabId: null, + url: null, + title: null, + loading: false, + ...profileStatus(), + }; + } + const url = tab.page.url(); + const viewport = tab.page.viewportSize(); + const status = { + available: true, + visible: tab.viewers.size > 0, + tabId: tab.tabId, + url: url === "about:blank" ? null : url, + title: null, + loading: tab.loading, + control: { + owner: tabOwner(tab), + ownedByCaller: tab.control.agentId === agentSessionId, + generation: tab.control.generation, + }, + dialog: dialogStatus(tab), + fileChooser: tab.fileChooser + ? { multiple: tab.fileChooser.chooser.isMultiple(), accept: tab.fileChooser.accept } + : null, + viewportSetting: tab.setting, + ...(viewport ? { viewport } : {}), + tabs: [...tabs.values()] + .filter((candidate) => candidate.threadId === tab.threadId) + .map((candidate) => ({ + tabId: candidate.tabId, + url: candidate.page.url() === "about:blank" ? null : candidate.page.url(), + ...(candidate.openerTabId === undefined ? {} : { openerTabId: candidate.openerTabId }), + owner: tabOwner(candidate), + ownedByCaller: candidate.control.agentId === agentSessionId, + visible: candidate.viewers.size > 0, + ...(candidate.profileId === undefined ? {} : { profileId: candidate.profileId }), + })), + ...profileStatus(), + downloads: tab.downloads.map(({ fileName, path, sizeBytes, url, completedAt }) => ({ + fileName, + path, + sizeBytes, + url, + completedAt, + })), + }; + if (status.url === null || tab.dialog) return status; + return { ...status, title: (await tab.page.title().catch(() => "")) || null }; + }; + + const resolveNavigationUrl = (input: PreviewAutomationNavigateInput) => { + if (input.url !== undefined) return normalizePreviewUrl(input.url); + const target = input.target!; + if (target.kind === "url") return normalizePreviewUrl(target.url); + // The browser runs inside the environment, so its ports are loopback. + const path = target.path ?? ""; + return `${target.protocol ?? "http"}://localhost:${target.port}${path.startsWith("/") || path === "" ? path : `/${path}`}`; + }; + + const navigate = async ( + tab: ServerTab, + url: string, + readiness: "load" | "domContentLoaded" | "none", + timeout: number, + ) => { + const navigation = tab.page.goto(url, { + timeout, + waitUntil: + readiness === "domContentLoaded" + ? "domcontentloaded" + : readiness === "none" + ? "commit" + : "load", + }); + if (readiness === "none") { + tab.control.track(navigation); + return; + } + await navigation.catch((cause: unknown) => { + const message = cause instanceof Error ? cause.message : String(cause); + if (message.includes("Download is starting")) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationExecutionError", + `${url} is a file this browser cannot show, so it was downloaded instead. preview_status lists it under downloads.`, + ); + } + if (/ERR_[A-Z_]+/.test(message)) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationExecutionError", + `Navigation to ${url} failed: ${ServerBrowserPage.parseNetError(message).description}`, + ); + } + throw cause; + }); + }; + + const withCaptureLock = (tab: ServerTab, operation: () => Promise): Promise => { + const run = tab.captureLock.then(() => { + if (tab.closing) throw new Error("The preview tab closed."); + return operation(); + }); + tab.captureLock = run.then(constVoid, constVoid); + return run; + }; + + const startRecording = (tab: ServerTab): Promise => { + const started = withCaptureLock(tab, async () => { + if (tab.recording) return tab.recording; + const encoder = await contexts.scratchPage(); + let session: CDPSession | null = null; + try { + await encoder.evaluate(ServerBrowserPage.RECORDING_ENCODER_SCRIPT); + // Seed idle pages before returning so an immediate stop has a frame to encode. + const firstFrame = await ServerBrowserPage.captureViewport(tab.page, tab.cdp, { + format: "jpeg", + quality: RECORDING_SCREENCAST.quality, + scale: 1, + }); + await encoder.evaluate( + ([data, width]) => + (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), + [firstFrame, tab.page.viewportSize()?.width ?? UNATTACHED_FILL_VIEWPORT.width] as const, + ); + const opened = await tab.page.context().newCDPSession(tab.page); + session = opened; + const framesInFlight = new Set>(); + opened.on("Page.screencastFrame", (frame) => { + const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth; + const delivered: Promise = encoder + .evaluate( + ([data, width]) => + (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), + [frame.data, cssWidth] as const, + ) + .then(async (accepted) => { + if (!accepted) await opened.send("Page.stopScreencast"); + }) + .catch(constVoid) + .finally(() => { + framesInFlight.delete(delivered); + void opened + .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) + .catch(constVoid); + }); + framesInFlight.add(delivered); + }); + await opened.send("Page.startScreencast", RECORDING_SCREENCAST); + if (tab.closing) throw new Error("The tab closed while the recording started."); + const recording: Recording = { + encoder, + session: opened, + startedAt: new Date().toISOString(), + framesInFlight, + }; + tab.recording = recording; + return recording; + } catch (cause) { + // A start that fails partway must not leave its encoder page behind. + await encoder.close().catch(constVoid); + await session?.detach().catch(constVoid); + throw cause; + } + }).finally(() => { + if (tab.recordingStart === started) tab.recordingStart = null; + }); + tab.recordingStart = started; + return started; + }; + + // Scaled captures repaint every screencast; pause them to avoid leaking that frame. + const withScreencastsPaused = (tab: ServerTab, capture: () => Promise): Promise => + withCaptureLock(tab, async () => { + tab.capturing += 1; + const recording = tab.recording; + try { + await Promise.all([ + ...[...tab.viewers].map((viewer) => viewer.pause()), + recording?.session.send("Page.stopScreencast").catch(constVoid), + ]); + return await capture(); + } finally { + tab.capturing -= 1; + // Viewers that attached during the capture start here too. + await Promise.all([ + ...[...tab.viewers].map((viewer) => viewer.resume()), + recording && tab.recording === recording + ? recording.session.send("Page.startScreencast", RECORDING_SCREENCAST).catch(constVoid) + : undefined, + ]); + } + }); + + const stopRecording = (tab: ServerTab) => + withCaptureLock(tab, async () => { + const recording = tab.recording; + if (!recording) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationRecordingNotActiveError", + "No recording is active for this tab.", + ); + } + let mimeType: string | null; + const chunks: Array = []; + try { + await recording.session.send("Page.stopScreencast").catch(constVoid); + // The last frames may still be on their way into the encoder. + await Promise.all(recording.framesInFlight); + await recording.session.detach().catch(constVoid); + const stopped = await recording.encoder.evaluate(() => + (globalThis as unknown as EncoderWindow).__t3Recorder.stop(), + ); + mimeType = stopped.mimeType; + // Checked before the transfer so an oversized video never lands in this process. + if (stopped.bytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationRecordingTooLargeError", + "The recording is larger than the attachment limit.", + ); + } + for (let index = 0; index < stopped.count; index += 1) { + const chunk = await recording.encoder.evaluate( + (chunkIndex) => (globalThis as unknown as EncoderWindow).__t3Recorder.chunk(chunkIndex), + index, + ); + chunks.push(Buffer.from(chunk, "base64")); + } + } finally { + await recording.encoder.close().catch(constVoid); + tab.recording = null; + } + const data = Buffer.concat(chunks); + if (!mimeType || data.byteLength === 0) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationExecutionError", + "The recording captured no frames.", + ); + } + // Use the desktop upload location so the MCP handler can claim the recording. + const extension = mimeType.startsWith("video/mp4") ? "mp4" : "webm"; + const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}-${extension}`; + const path = NodePath.join(config.attachmentsDir, `${pendingId}.${extension}`); + await NodeFSP.mkdir(config.attachmentsDir, { recursive: true }); + await NodeFSP.writeFile(path, data); + return { + id: pendingId, + tabId: tab.tabId, + path, + mimeType: mimeType.split(";")[0]!, + sizeBytes: data.byteLength, + createdAt: new Date().toISOString(), + uploadedAttachmentId: pendingId, + }; + }); + + let pointerSequence = 0; + /** + * Shows viewers and recordings where the agent is about to act. Nobody is + * watching a headless agent tab, so it pays no glide delay. + */ + const pointerFor = + (tab: ServerTab): ServerBrowserPage.PointerReporter => + async ({ x, y }, phase) => { + const encoder = tab.recording?.encoder; + // A desktop-rendered tab is always on screen in the desktop app. + if (tab.viewers.size === 0 && !encoder && !tab.desktop) return; + const show = (next: "move" | "click") => { + const sequence = ++pointerSequence; + for (const viewer of tab.viewers) + viewer.push({ _tag: "pointer", phase: next, x, y, sequence }); + if (tab.desktop) + runFork( + desktopChannel.pointer( + { threadId: tab.threadId, tabId: tab.tabId }, + { phase: next, x, y }, + ), + ); + void encoder + ?.evaluate( + ([px, py, click]) => + (globalThis as unknown as EncoderWindow).__t3Recorder?.cursor(px, py, click), + [x, y, next === "click"] as const, + ) + .catch(constVoid); + }; + show("move"); + await sleepUntil(Date.now() + AGENT_CURSOR_MOVE_MS); + if (phase !== "click") return; + show("click"); + await sleepUntil(Date.now() + AGENT_CURSOR_CLICK_LEAD_MS); + }; + + const recordAction = (tab: ServerTab, action: string, run: () => Promise): Promise => { + const event: { + -readonly [K in keyof PreviewAutomationActionEvent]: PreviewAutomationActionEvent[K]; + } = { + id: NodeCrypto.randomUUID(), + action, + status: "running", + startedAt: new Date().toISOString(), + }; + pushBounded(tab.actionTimeline, event, ACTION_TIMELINE_LIMIT); + return run().then( + (result) => { + event.status = "succeeded"; + event.completedAt = new Date().toISOString(); + return result; + }, + (cause: unknown) => { + event.status = "failed"; + event.completedAt = new Date().toISOString(); + event.error = cause instanceof Error ? cause.message.split("\n")[0] : String(cause); + throw cause; + }, + ); + }; + + const requireTab = async (request: PreviewAutomationRequest) => { + if (!request.agentSessionId) + throw new BrowserControlInterrupted("The agent session is missing. Reconnect the provider."); + const owned = [...tabs.values()].filter( + (tab) => tab.threadId === request.threadId && tab.control.agentId === request.agentSessionId, + ); + if (!request.tabIdExplicit && owned.length > 1) + throw new BrowserControlInterrupted( + "Multiple tabs belong to this agent session. Pass a tabId from preview_open or preview_status tabs.", + "tabRequired", + ); + const tab = + request.tabId === undefined + ? latestThreadTab(request.threadId, request.agentSessionId) + : await Effect.runPromise( + findTab(request.threadId, request.tabId).pipe(Effect.orElseSucceed(constVoid)), + ); + if (!tab) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationTabNotFoundError", + "No server preview tab is open for this thread. Call preview_open first.", + ); + } + // Any tab in the thread can be read; acting is checked by its control. + if (!READ_OPERATIONS.has(request.operation) && !tab.control.agentMayAct(request.agentSessionId)) + throw new BrowserControlInterrupted( + "This tab belongs to another agent session. You can read it, but open your own tab to act.", + "agentMismatch", + ); + return tab; + }; + + /** + * Any request, even a failed one, keeps the agent's tabs on the thread from + * idling out. The tab it targeted becomes the thread's most recently used. + */ + const markUsed = (request: PreviewAutomationRequest) => { + const now = Date.now(); + for (const tab of tabs.values()) { + if ( + tab.threadId === request.threadId && + (tab.control.agentId === request.agentSessionId || tab.tabId === request.tabId) + ) + tab.usedAt = now; + } + }; + + const runOperation = async (request: PreviewAutomationRequest): Promise => { + const input = request.input; + switch (request.operation) { + case "status": + return statusWithTitle( + request.tabId === undefined + ? latestThreadTab(request.threadId, request.agentSessionId) + : tabs.get(tabKey(request.threadId, request.tabId)), + request.agentSessionId, + ); + case "open": { + if (!request.agentSessionId) + throw new BrowserControlInterrupted( + "The agent session is missing. Reconnect the provider.", + ); + const open = input as PreviewAutomationOpenInput; + const url = open.url === undefined ? undefined : normalizePreviewUrl(open.url); + const reuse = open.reuseExistingTab ?? true; + if ( + reuse && + !request.tabIdExplicit && + [...tabs.values()].filter( + (tab) => + tab.threadId === request.threadId && tab.control.agentId === request.agentSessionId, + ).length > 1 + ) + throw new BrowserControlInterrupted( + "Multiple tabs are open. Pass tabId or reuseExistingTab=false.", + "tabRequired", + ); + // A tab still launching exists only as a session, so resolve it like a viewer would. + const found = + reuse && request.tabId !== undefined + ? await Effect.runPromise( + findTab(request.threadId, request.tabId).pipe( + Effect.catchTags({ + ServerBrowserTabNotFoundError: () => Effect.succeed(undefined), + }), + ), + ) + : undefined; + // Only an explicit tabId reuses a tab this session did not open; a tab + // it last read (such as the user's) is not taken over implicitly. + const existing = + found && (request.tabIdExplicit || found.control.agentId === request.agentSessionId) + ? found + : undefined; + const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS); + const profileId = existing ? undefined : resolveOpenProfile(open.profileId); + if (!existing) { + closeIdleAgentTabs(); + assertTabCapacity(request.agentSessionId); + } + const tab = + existing ?? + (await ensureTab( + await Effect.runPromise( + manager.open({ + threadId: request.threadId, + ...(url ? { url } : {}), + ...(profileId === undefined ? {} : { profileId }), + runtime: "server", + reveal: false, + automationOwner: request.agentSessionId, + }), + ), + )); + if (existing?.dialog) + throw new BrowserControlInterrupted( + "A browser dialog is pending. Read preview_status and use preview_dialog first.", + "dialogPending", + ); + return tab.control.agent(request.agentSessionId, async () => { + if (tab.dialog) + throw new BrowserControlInterrupted( + "A browser dialog is pending. Read preview_status and use preview_dialog first.", + "dialogPending", + ); + if (existing) { + if (url) await navigate(tab, url, "load", navigationTimeout); + } else { + // Await the original navigation failure even though background creation keeps the tab. + await tab.initialNavigation; + } + const reveal = open.open ?? open.show; + if (reveal !== false) { + await Effect.runPromise( + manager.requestReveal({ + threadId: tab.threadId, + tabId: tab.tabId, + force: reveal === true, + }), + ); + } + if (!existing && url) { + await tab.page + .waitForLoadState("load", { timeout: navigationTimeout }) + .catch(constVoid); + } + return statusWithTitle(tab, request.agentSessionId); + }); + } + case "recordingStop": { + if ( + !request.tabIdExplicit && + [...tabs.values()].filter( + (candidate) => + candidate.threadId === request.threadId && + candidate.control.agentId === request.agentSessionId, + ).length > 1 + ) + throw new BrowserControlInterrupted( + "Multiple tabs belong to this agent session. Pass a tabId from preview_open or preview_status tabs.", + "tabRequired", + ); + const recordings = [...tabs.values()].filter( + (candidate) => + candidate.threadId === request.threadId && + candidate.control.agentMayAct(request.agentSessionId ?? "") && + (candidate.recording || candidate.recordingStart), + ); + const targetTabId = + request.tabId ?? latestThreadTab(request.threadId, request.agentSessionId)?.tabId; + const tab = + recordings.find((candidate) => candidate.tabId === targetTabId) ?? + (!request.tabIdExplicit && recordings.length === 1 ? recordings[0] : undefined); + if (!tab) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationRecordingNotActiveError", + "No recording is active for this thread.", + ); + } + return tab.control.agent(request.agentSessionId ?? "", () => stopRecording(tab)); + } + } + const tab = await requireTab(request); + // Closing must unblock an action waiting on a dialog, without queueing behind it. + if (request.operation === "close") { + if (tab.control.agentId !== request.agentSessionId) + throw new BrowserControlInterrupted( + "Only the agent session that opened this tab can close it.", + "agentMismatch", + ); + if (tab.control.controller !== null) + throw new BrowserControlInterrupted("A human controls this tab.", "humanControl"); + void tab.control.close().catch(constVoid); + await Effect.runPromise(manager.close({ threadId: tab.threadId, tabId: tab.tabId })); + dropTab(tab, false); + return {}; + } + // A click can be waiting for its dialog. Resolve it outside the serial queue, + // with the same owner check, so the operation can finish and control can drain. + if (request.operation === "dialog") { + if (tab.control.controller !== null) + throw new BrowserControlInterrupted("A human controls this tab.", "humanControl"); + await resolveDialog(tab, input as PreviewAutomationDialogInput); + return statusWithTitle(tab, request.agentSessionId); + } + const agentSessionId = request.agentSessionId!; + // A tab this session did not open, while it cannot act there (another + // agent's, or the user's while they drive): read it in place, without + // queueing behind the human's input. Its own tabs keep the takeover + // contract: a human taking control interrupts every agent call. + if ( + READ_OPERATIONS.has(request.operation) && + tab.control.agentId !== agentSessionId && + !tab.control.agentCanActNow(agentSessionId) + ) { + return tab.control.observe(async () => { + if (tab.dialog) + throw new BrowserControlInterrupted( + "A browser dialog is pending. Read preview_status.", + "dialogPending", + ); + return executeTabOperation(tab, request); + }); + } + return tab.control.agent(agentSessionId, async () => { + if (tab.dialog) + throw new BrowserControlInterrupted( + "A browser dialog is pending. Read preview_status and use preview_dialog first.", + "dialogPending", + ); + const generation = tab.control.generation; + try { + return await executeTabOperation(tab, request); + } finally { + if (generation !== tab.control.generation) ServerBrowserPage.invalidateRefs(tab.page); + } + }); + }; + + const executeTabOperation = async (tab: ServerTab, request: PreviewAutomationRequest) => { + const input = request.input; + switch (request.operation) { + case "navigate": { + const navigateInput = input as PreviewAutomationNavigateInput; + await recordAction(tab, "navigate", () => + navigate( + tab, + resolveNavigationUrl(navigateInput), + navigateInput.readiness ?? "load", + navigateInput.timeoutMs ?? request.timeoutMs, + ), + ); + return statusWithTitle(tab, request.agentSessionId); + } + case "resize": { + const setting = resolvePreviewViewport(input as PreviewAutomationResizeInput); + await Effect.runPromise( + manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }), + ); + await applySetting(tab, setting); + return { + tabId: tab.tabId, + setting, + viewport: tab.page.viewportSize() ?? UNATTACHED_FILL_VIEWPORT, + }; + } + case "setColorScheme": { + const { colorScheme } = input as PreviewAutomationSetColorSchemeInput; + const snapshot = await Effect.runPromise( + manager.adjust({ threadId: tab.threadId, tabId: tab.tabId, colorScheme }), + ); + await applyRendering(tab, snapshot); + return { tabId: tab.tabId, colorScheme }; + } + case "snapshot": { + return withScreencastsPaused(tab, () => + ServerBrowserPage.snapshot({ ...tab, renderScale: RENDER_SCALE }), + ); + } + case "click": { + const clickInput = input as PreviewAutomationClickInput; + await recordAction(tab, "click", () => + ServerBrowserPage.click(tab.page, clickInput, pointerFor(tab)), + ); + return undefined; + } + case "hover": + return recordAction(tab, "hover", () => + ServerBrowserPage.hover(tab.page, input as PreviewAutomationHoverInput, pointerFor(tab)), + ); + case "select": + return recordAction(tab, "select", () => + ServerBrowserPage.select(tab.page, input as PreviewAutomationSelectInput), + ); + case "drag": + return recordAction(tab, "drag", () => + ServerBrowserPage.drag(tab.page, input as PreviewAutomationDragInput, pointerFor(tab)), + ); + case "upload": + return recordAction(tab, "upload", () => + uploadFiles(tab, input as PreviewAutomationUploadInput), + ); + case "type": + return recordAction(tab, "type", () => + ServerBrowserPage.type(tab.page, input as PreviewAutomationTypeInput), + ); + case "press": + return recordAction(tab, "press", () => + ServerBrowserPage.press(tab.page, input as PreviewAutomationPressInput), + ); + case "scroll": + return recordAction(tab, "scroll", () => + ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput), + ); + case "evaluate": + return ServerBrowserPage.evaluate( + tab.cdp, + input as PreviewAutomationEvaluateInput, + request.timeoutMs, + ); + case "waitFor": + return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput); + case "recordingStart": { + const recording = await startRecording(tab); + return { tabId: tab.tabId, recording: true, startedAt: recording.startedAt }; + } + } + }; + + const handleRequest = (connectionId: string, request: PreviewAutomationRequest) => + Effect.tryPromise({ + try: () => runOperation(request).finally(() => markUsed(request)), + catch: ServerBrowserPage.toOperationError, + }).pipe( + Effect.match({ + onSuccess: (result) => ({ ok: true as const, result }), + onFailure: (error) => ({ + ok: false as const, + error: { + _tag: error.tag, + message: error.message, + ...(error.detail === undefined ? {} : { detail: error.detail }), + }, + }), + }), + Effect.flatMap((outcome) => + broker.respond({ + clientId: SERVER_HOST_CLIENT_ID, + connectionId, + requestId: request.requestId, + ...outcome, + }), + ), + Effect.ignore, + ); + + const mirrorManagerEvent = (event: PreviewEvent) => + Effect.promise(async () => { + if (event.type === "opened" && event.snapshot.runtime === "server") { + await ensureTab(event.snapshot).catch(constVoid); + return; + } + const key = tabKey(event.threadId, event.tabId); + const tab = tabs.get(key); + if (event.type === "closed" && !tab && pendingTabs.has(key)) closedPendingTabs.add(key); + if (!tab) return; + if (event.type === "closed") { + closedSessions.add(key); + dropTab(tab, false); + closedSessions.delete(key); + return; + } + // Any client or agent may change these; the page follows what was published. + if (event.type === "resized") { + await tab.control + .system(async () => { + if ( + !viewportSettingsEqual(event.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, tab.setting) + ) { + await applySetting(tab, event.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT); + } + await applyRendering(tab, event.snapshot); + const request = event.request; + if (request?.clear === "cookies") await tab.cdp.send("Network.clearBrowserCookies"); + if (request?.clear === "cache") await tab.cdp.send("Network.clearBrowserCache"); + if (request?.hardReload) await tab.cdp.send("Page.reload", { ignoreCache: true }); + }) + .catch((cause: unknown) => + runFork(Effect.logWarning("server preview could not apply a tab setting", { cause })), + ); + } + }); + + const releaseViewerInput = async (viewer: ViewerState, session: CDPSession) => { + for (const { key, code } of viewer.pressedKeys.values()) { + await session.send("Input.dispatchKeyEvent", { type: "keyUp", key, code }).catch(constVoid); + } + viewer.pressedKeys.clear(); + for (const [button, point] of viewer.pressedButtons) { + await session + .send("Input.dispatchMouseEvent", { + type: "mouseReleased", + button, + ...point, + buttons: 0, + clickCount: 1, + }) + .catch(constVoid); + } + viewer.pressedButtons.clear(); + viewer.requestedSize = null; + }; + + const dispatchViewerInput = async ( + tab: ServerTab, + session: CDPSession, + viewer: ViewerState, + raw: unknown, + ) => { + const message = asRecord(raw); + if (!message) return; + viewer.inputAt = Date.now(); + const modifiers = modifiersOf(message); + switch (message.type) { + case "mouse": { + const action = message.action; + const type = + action === "down" ? "mousePressed" : action === "up" ? "mouseReleased" : "mouseMoved"; + const button = ["none", "left", "middle", "right"].includes(String(message.button)) + ? (message.button as "none" | "left" | "middle" | "right") + : "none"; + await session.send("Input.dispatchMouseEvent", { + type, + x: num(message.x), + y: num(message.y), + button, + buttons: num(message.buttons), + clickCount: num(message.clickCount, type === "mouseMoved" ? 0 : 1), + modifiers, + }); + if (button !== "none") { + if (action === "down") + viewer.pressedButtons.set(button, { x: num(message.x), y: num(message.y) }); + else if (action === "up") viewer.pressedButtons.delete(button); + } + return; + } + case "wheel": + viewer.scrolledAt = Date.now(); + await session.send("Input.dispatchMouseEvent", { + type: "mouseWheel", + x: num(message.x), + y: num(message.y), + deltaX: num(message.deltaX), + deltaY: num(message.deltaY), + modifiers, + }); + return; + case "key": { + const key = typeof message.key === "string" ? message.key : ""; + const code = typeof message.code === "string" ? message.code : ""; + const text = typeof message.text === "string" ? message.text : undefined; + if (message.action === "up") { + await session.send("Input.dispatchKeyEvent", { type: "keyUp", key, code, modifiers }); + viewer.pressedKeys.delete(code || key); + return; + } + await session.send("Input.dispatchKeyEvent", { + type: text ? "keyDown" : "rawKeyDown", + key, + code, + modifiers, + ...(text ? { text, unmodifiedText: text } : {}), + ...editingCommand(key, modifiers), + windowsVirtualKeyCode: num( + message.keyCode, + key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0, + ), + }); + viewer.pressedKeys.set(code || key, { key, code }); + return; + } + case "text": + if (typeof message.text === "string" && message.text.length > 0) { + await session.send("Input.insertText", { text: message.text.slice(0, 10_000) }); + } + return; + case "resize": { + const width = Math.min(Math.round(num(message.width)), 3840); + const height = Math.min(Math.round(num(message.height)), 2160); + if (width < 100 || height < 100) return; + viewer.requestedSize = { width, height, order: ++viewerResizeOrder }; + if (tab.setting._tag !== "fill" || tab.desktop) return; + const current = tab.page.viewportSize(); + if (current?.width === width && current.height === height) return; + await tab.page.setViewportSize({ width, height }); + broadcastViewport(tab); + return; + } + case "viewport": { + const setting = decodeViewportSetting(message.setting); + await Effect.runPromise( + manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }), + ); + await applySetting(tab, setting); + return; + } + case "navigate": + if (typeof message.url === "string") { + const url = normalizePreviewUrl(message.url); + await tab.page.goto(url, VIEWER_NAVIGATION_OPTIONS); + } + return; + case "history": + await (num(message.delta) < 0 + ? tab.page.goBack(VIEWER_NAVIGATION_OPTIONS) + : tab.page.goForward(VIEWER_NAVIGATION_OPTIONS)); + return; + case "reload": + // A hard reload fetches everything again, as Chrome's Shift+Reload does. + if (message.ignoreCache === true) { + await session.send("Page.reload", { ignoreCache: true }); + return; + } + await tab.page.reload(VIEWER_NAVIGATION_OPTIONS); + return; + case "probe": { + const x = num(message.x); + const y = num(message.y); + const result = await session.send("Runtime.evaluate", { + expression: `(${EDITABLE_AT_POINT_SCRIPT})(${x}, ${y})`, + returnByValue: true, + }); + viewer.push({ _tag: "probe", x, y, editable: result.result.value === true }); + return; + } + } + }; + + const attachViewer: ServerBrowser["Service"]["attachViewer"] = (input) => + Effect.gen(function* () { + const tab = yield* findTab(input.threadId, input.tabId); + const output = yield* Queue.make({ + capacity: VIEWER_OUTPUT_LIMIT, + strategy: "dropping", + }); + const session = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => tab.page.context().newCDPSession(tab.page), + catch: (cause) => new ServerBrowserLaunchError({ cause }), + }), + (session) => + Effect.promise(() => + session + .send("Page.stopScreencast") + .catch(constVoid) + .then(() => session.detach()) + .catch(constVoid), + ), + ); + const quality = Math.min(100, Math.max(1, Math.round(input.quality))); + // Chromium may drop the final frame during a burst; send a still when it settles. + let framesInFlight = 0; + let mayHaveDropped = false; + let motion = false; + const recentFrames: Array = []; + let screencastParams = Promise.resolve(); + let screencastScale = 1; + const startScreencast = (scale: number) => { + screencastScale = scale; + screencastParams = screencastParams.then(async () => { + // A scaled capture is rendering; its resume starts the stream. + if (tab.capturing > 0) return; + await session.send("Page.stopScreencast").catch(constVoid); + await session + .send("Page.startScreencast", { + format: "jpeg", + quality: screencastScale < 1 ? Math.min(quality, SCREENCAST_MOTION_QUALITY) : quality, + maxWidth: Math.max(1, Math.round(input.maxWidth * screencastScale)), + maxHeight: Math.max(1, Math.round(input.maxHeight * screencastScale)), + }) + .catch(constVoid); + }); + return screencastParams; + }; + const viewer: ViewerState = { + id: NodeCrypto.randomUUID(), + canOperate: input.canOperate, + pressedKeys: new Map(), + pressedButtons: new Map(), + push: (next) => { + // Dropped frames must still release Chromium. + if (Queue.offerUnsafe(output, next)) return; + if (next._tag === "frame") runFork(next.ack); + // State a stalled viewer cannot miss replaces its backlog. It runs + // synchronously so an older replacement can never land after a newer one. + else if (next._tag === "gone" || next._tag === "control" || next._tag === "fileChooser") { + const dropped = Effect.runSyncExit(Queue.clear(output)); + if (dropped._tag === "Failure") return; + Queue.offerUnsafe(output, next); + // The controller's open picker may have been in the dropped backlog. + const chooser = next._tag === "control" ? fileChooserMessage(tab) : null; + if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); + for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); + } + }, + pause: () => { + screencastParams = screencastParams.then(() => + session.send("Page.stopScreencast").then(constVoid, constVoid), + ); + return screencastParams; + }, + resume: () => startScreencast(screencastScale), + scrolledAt: 0, + inputAt: 0, + requestedSize: null, + }; + yield* Effect.acquireRelease( + Effect.sync(() => { + tab.viewers.add(viewer); + reportLiveTabs(); + }), + () => + Effect.promise(async () => { + await tab.control.disconnect(viewer.id, () => releaseViewerInput(viewer, session)); + tab.viewers.delete(viewer); + broadcastControl(tab); + reportLiveTabs(); + }), + ); + if (input.canOperate && tab.control.agentId === null && tab.control.controller === null) { + yield* Effect.promise(() => tab.control.take(viewer.id)); + } + broadcastControl(tab); + pushFileChooser(tab); + // Full scale: a scaled capture would flash in every other viewer. + const pushStill = async () => { + const data = await withCaptureLock(tab, () => + ServerBrowserPage.captureViewport(tab.page, session, { + format: "jpeg", + quality, + scale: 1, + }), + ).catch(() => null); + if (data) + viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); + }; + let settleTimer: ReturnType | null = null; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (settleTimer !== null) clearTimeout(settleTimer); + }), + ); + let screencastStarted = false; + session.on("Page.screencastFrame", (frame) => { + screencastStarted = true; + if (framesInFlight > 0) mayHaveDropped = true; + framesInFlight += 1; + const arrivedAt = Date.now(); + recentFrames.push(arrivedAt); + while (recentFrames[0]! < arrivedAt - SCREENCAST_MOTION_WINDOW_MS) recentFrames.shift(); + if ( + !motion && + recentFrames.length >= SCREENCAST_MOTION_FRAMES && + arrivedAt - viewer.scrolledAt < SCREENCAST_MOTION_WINDOW_MS + ) { + motion = true; + void startScreencast(0.5); + } else if (motion && arrivedAt - viewer.scrolledAt > SCREENCAST_SETTLE_MS) { + // The page keeps animating after the scroll; its own frames are sharp again. + motion = false; + recentFrames.length = 0; + void startScreencast(1); + } + if (settleTimer !== null) clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + settleTimer = null; + if (!motion && !mayHaveDropped) return; + mayHaveDropped = false; + if (motion) { + motion = false; + recentFrames.length = 0; + void startScreencast(1); + } + void pushStill(); + }, SCREENCAST_SETTLE_MS); + viewer.push({ + _tag: "frame", + data: Buffer.from(frame.data, "base64"), + ack: Effect.promise(() => + sleepUntil(arrivedAt + SCREENCAST_ACK_PACE_MS).then(() => { + framesInFlight -= 1; + return session + .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) + .catch(constVoid); + }), + ), + }); + }); + broadcastViewport(tab); + yield* Effect.promise(() => startScreencast(1)); + // An idle page does not repaint for a new screencast, so the viewer + // starts from a still unless a live frame beat it. + if (!screencastStarted) yield* Effect.promise(pushStill); + return { + output, + input: (raw: unknown) => + Effect.promise(async () => { + if (!viewer.canOperate) return; + const message = asRecord(raw); + if (!message) return; + try { + if (message.type === "takeControl") { + const taking = tab.control.take(viewer.id); + broadcastControl(tab); + await taking; + pushFileChooser(tab); + } else if (message.type === "releaseControl") { + const releasing = tab.control.release(viewer.id, () => + releaseViewerInput(viewer, session), + ); + broadcastControl(tab); + await releasing; + } else if ( + message.type === "dialog" && + tab.control.controller === viewer.id && + typeof message.accept === "boolean" + ) { + await resolveDialog(tab, { + accept: message.accept, + ...(typeof message.promptText === "string" + ? { promptText: message.promptText } + : {}), + }); + } else { + await tab.control.human(viewer.id, () => + dispatchViewerInput(tab, session, viewer, message), + ); + } + } catch { + // Rejected ownership cannot mutate the page; refresh the viewer's controls. + broadcastControl(tab); + } + }), + } satisfies ServerBrowserViewer; + }); + + // Whoever runs the server learns the fix before anyone opens a tab. + if ( + !PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment) && + (yield* PreviewBrowserHost.sandboxBlocked) + ) { + yield* Effect.logWarning( + `This host blocks the sandbox T3's browser runs in, so browser tabs and HTML previews will not start. Run \`${setupCommand}\` once to allow it.`, + ); + } + // The desktop took its page back (closed, swapped, crashed, or devtools opened). + // The session stays; the next viewer or agent reconnects when it re-attaches. + yield* desktopChannel.detached.pipe( + Stream.runForEach((key) => + Effect.sync(() => { + const tab = tabs.get(tabKey(key.threadId, key.tabId)); + if (tab?.desktop) dropTab(tab, false); + }), + ), + Effect.forkScoped, + ); + // The page came back, for example after its DevTools closed. Reconnect now + // rather than on the next viewer or agent, or the tab's URL and title stop + // reaching every client in the meantime. + yield* desktopChannel.attached.pipe( + Stream.runForEach((key) => + Effect.gen(function* () { + if (tabs.has(tabKey(key.threadId, key.tabId))) return; + const { sessions } = yield* manager.list({ threadId: ThreadId.make(key.threadId) }); + const snapshot = sessions.find( + (session) => session.tabId === key.tabId && session.runtime === "server", + ); + if (snapshot) yield* Effect.promise(() => ensureTab(snapshot).catch(constVoid)); + }).pipe(Effect.ignore), + ), + Effect.forkScoped, + ); + yield* Effect.sync(closeIdleAgentTabs).pipe( + Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)), + Effect.forkScoped, + ); + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await contexts.close(); + }), + ); + + const clearProfile = (profileId: string) => + Effect.tryPromise({ + try: () => contexts.clearProfile(profileId), + catch: (cause) => new PreviewClearProfileError({ profileId, cause }), + }); + + return { + handleRequest, + mirrorManagerEvent, + onHostConnected: (connectionId: string) => + Effect.sync(() => { + hostConnectionId = connectionId; + reportLiveTabs(); + }), + service: ServerBrowser.of({ + attachViewer, + clearProfile, + reportProfiles, + openDownload, + answerFileChooser, + }), + }; +}); diff --git a/apps/server/src/provider/ClaudeAgentSdkQueryRunner.ts b/apps/server/src/provider/ClaudeAgentSdkQueryRunner.ts new file mode 100644 index 0000000000000..2a42594febbf5 --- /dev/null +++ b/apps/server/src/provider/ClaudeAgentSdkQueryRunner.ts @@ -0,0 +1,58 @@ +import type { ForkSessionResult } from "@anthropic-ai/claude-agent-sdk"; +import type { + ClaudeAgentSdkQueryRunnerError, + ClaudeAgentSdkQueryOpenInput, + ClaudeAgentSdkQuerySession, + ClaudeAgentSdkSessionForkInput, + ClaudeAgentSdkSubagentLookupInput, +} from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; +import type * as Crypto from "effect/Crypto"; +import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; + +export class ClaudeAgentSdkQueryRunner extends Context.Service< + ClaudeAgentSdkQueryRunner, + { + readonly allocateSessionId: Effect.Effect; + readonly open: ( + input: ClaudeAgentSdkQueryOpenInput, + ) => Effect.Effect; + readonly forkSession: ( + input: ClaudeAgentSdkSessionForkInput, + ) => Effect.Effect; + /** The tool call that launched a subagent, read from the CLI's session storage. */ + readonly subagentLaunchToolUseId: ( + input: ClaudeAgentSdkSubagentLookupInput, + ) => Effect.Effect; + readonly assertComplete: Effect.Effect; + } +>()("t3/provider/ClaudeAgentSdkQueryRunner") {} + +export const layer: Layer.Layer< + ClaudeAgentSdkQueryRunner, + never, + Crypto.Crypto | ProviderEventLoggers.ProviderEventLoggers +> = Layer.effect( + ClaudeAgentSdkQueryRunner, + Effect.gen(function* () { + const context = yield* Effect.context | Scope.Scope>(); + const service = yield* Effect.cached( + Effect.promise(() => import("../orchestration-v2/Adapters/ClaudeAdapterV2.ts")).pipe( + Effect.flatMap((adapter) => Layer.build(adapter.layerQueryRunner)), + Effect.map((services) => Context.get(services, ClaudeAgentSdkQueryRunner)), + Effect.provideContext(context), + ), + ); + return ClaudeAgentSdkQueryRunner.of({ + allocateSessionId: service.pipe(Effect.flatMap((value) => value.allocateSessionId)), + open: (input) => service.pipe(Effect.flatMap((value) => value.open(input))), + forkSession: (input) => service.pipe(Effect.flatMap((value) => value.forkSession(input))), + subagentLaunchToolUseId: (input) => + service.pipe(Effect.flatMap((value) => value.subagentLaunchToolUseId(input))), + assertComplete: service.pipe(Effect.flatMap((value) => value.assertComplete)), + }); + }), +); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 553d3d2e5fefc..7d18fde7e2ae3 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -15,13 +15,12 @@ import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { - query as claudeQuery, - type Options as ClaudeQueryOptions, - type SlashCommand as ClaudeSlashCommand, - type SDKControlGetUsageResponse, - type SDKUserMessage, - type SettingSource, +import type { + Options as ClaudeQueryOptions, + SlashCommand as ClaudeSlashCommand, + SDKControlGetUsageResponse, + SDKUserMessage, + SettingSource, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -380,6 +379,7 @@ const probeClaudeCapabilities = ( claudeEnvironment, ); return yield* Effect.tryPromise(async () => { + const { query: claudeQuery } = await import("@anthropic-ai/claude-agent-sdk"); const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. // This prevents any prompt from reaching the Anthropic API. diff --git a/apps/server/src/provider/CodexAppServerClientFactory.ts b/apps/server/src/provider/CodexAppServerClientFactory.ts new file mode 100644 index 0000000000000..0e8f3c9f5a592 --- /dev/null +++ b/apps/server/src/provider/CodexAppServerClientFactory.ts @@ -0,0 +1,53 @@ +import type { + ProviderInstanceId, + ThreadId, + OrchestrationV2ProviderSession, + CodexSettings, +} from "@t3tools/contracts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as CodexClient from "effect-codex-app-server/client"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; +import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; + +export class CodexAppServerClientFactory extends Context.Service< + CodexAppServerClientFactory, + { + readonly open: (input: { + readonly instanceId: ProviderInstanceId; + readonly threadId: ThreadId; + readonly providerSessionId: OrchestrationV2ProviderSession["id"]; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + readonly settings: CodexSettings; + readonly environment: NodeJS.ProcessEnv; + }) => Effect.Effect< + CodexClient.CodexAppServerClient["Service"], + ProviderAdapter.ProviderAdapterOpenSessionError, + Scope.Scope + >; + } +>()("t3/provider/CodexAppServerClientFactory") {} + +export const layer: Layer.Layer< + CodexAppServerClientFactory, + never, + ChildProcessSpawner.ChildProcessSpawner | ProviderEventLoggers.ProviderEventLoggers +> = Layer.effect( + CodexAppServerClientFactory, + Effect.gen(function* () { + const context = yield* Effect.context | Scope.Scope>(); + const service = yield* Effect.cached( + Effect.promise(() => import("../orchestration-v2/Adapters/CodexAdapterV2.ts")).pipe( + Effect.flatMap((adapter) => Layer.build(adapter.layerAppServerClientFactory)), + Effect.map((services) => Context.get(services, CodexAppServerClientFactory)), + Effect.provideContext(context), + ), + ); + return CodexAppServerClientFactory.of({ + open: (input) => service.pipe(Effect.flatMap((value) => value.open(input))), + }); + }), +); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index cb1a5513cd01c..1d0ece993dc71 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -9,8 +9,8 @@ import * as Scope from "effect/Scope"; import * as Types from "effect/Types"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as CodexClient from "effect-codex-app-server/client"; -import * as CodexSchema from "effect-codex-app-server/schema"; +import type * as CodexClient from "effect-codex-app-server/client"; +import type * as CodexSchema from "effect-codex-app-server/schema"; import * as CodexErrors from "effect-codex-app-server/errors"; import type { @@ -407,6 +407,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu }), ), ); + const CodexClient = yield* Effect.promise(() => import("effect-codex-app-server/client")); const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index c82565e8f2fe7..2169b0e74b252 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -49,7 +49,7 @@ import { } from "../acp/AntigravitySessionFiles.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; -import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; import { ProviderDriverError } from "../Errors.ts"; import { makeAntigravityProvider } from "../AntigravityProvider.ts"; @@ -405,29 +405,39 @@ export const AntigravityDriver: ProviderDriver< ), ); const defaultModel = currentCatalog.pipe(Effect.map((catalog) => catalog?.defaultChatModel)); - const orchestrationAdapter = yield* makeAntigravityAdapterV2({ + const orchestrationAdapter = yield* makeLazyAdapter( instanceId, - selfInvocation, - makeRuntime, - withProcess: authFlow.withProcess, - defaultModel, - onSessionStarted: provider.onSessionStarted, - onSessionEvent: (event) => { - if (event._tag === "ConfigOptionsUpdated") { - return provider.onConfigOptionsUpdated(event.configOptions); - } - return event._tag === "AvailableCommandsUpdated" - ? provider.onAvailableCommands(event.availableCommands) - : Effect.void; - }, - continuationRequests, - nativeLogging: (threadId) => - makeNativeLogger({ - nativeEventLogger: loggers.native, - provider: DRIVER, - threadId, - }), - }); + DRIVER, + Effect.promise( + () => import("../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"), + ).pipe( + Effect.flatMap(({ makeAntigravityAdapterV2 }) => + makeAntigravityAdapterV2({ + instanceId, + selfInvocation, + makeRuntime, + withProcess: authFlow.withProcess, + defaultModel, + onSessionStarted: provider.onSessionStarted, + onSessionEvent: (event) => { + if (event._tag === "ConfigOptionsUpdated") { + return provider.onConfigOptionsUpdated(event.configOptions); + } + return event._tag === "AvailableCommandsUpdated" + ? provider.onAvailableCommands(event.availableCommands) + : Effect.void; + }, + continuationRequests, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: loggers.native, + provider: DRIVER, + threadId, + }), + }), + ), + ), + ); const textGeneration = yield* makeAntigravityTextGeneration({ profileDirectory, defaultModel, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index be7872a7df283..f0f1c7ed4ac45 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -1,3 +1,4 @@ +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; /** * ClaudeDriver — `ProviderDriver` for the Claude Agent SDK runtime. * @@ -29,10 +30,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; -import { - createClaudeAdapterV2, - type ClaudeAdapterV2DriverEnv, -} from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import type { ClaudeAdapterV2DriverEnv } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import { ProviderDriverError } from "../Errors.ts"; import { claudeUsageReader } from "./claudeUsage.ts"; import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts"; @@ -170,25 +168,33 @@ export const ClaudeDriver: ProviderDriver snapshot.applyUsageLimits(update) }, - ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Claude orchestration adapter.", - cause, - }), + const orchestrationAdapter = yield* makeLazyAdapter( + instanceId, + DRIVER_KIND, + Effect.promise(() => import("../../orchestration-v2/Adapters/ClaudeAdapterV2.ts")).pipe( + Effect.flatMap(({ createClaudeAdapterV2 }) => + createClaudeAdapterV2( + { + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }, + { scopedLimitNames, onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Claude orchestration adapter.", + cause, + }), + ), + ), + ), ), ); const textGeneration = yield* makeClaudeTextGeneration( diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 7ac25a2e6bc13..f2332e8f8a377 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -1,3 +1,4 @@ +import * as CodexAppServerClientFactoryService from "../CodexAppServerClientFactory.ts"; import * as CodexInstallation from "../CodexInstallation.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; @@ -35,7 +36,6 @@ import { resolveLatestProviderVersion, } from "@t3tools/provider-core/server/maintenanceResolver"; import { CodexDriver } from "./CodexDriver.ts"; -import * as CodexAdapterV2 from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderCredentialStore from "../ProviderCredentialStore.ts"; @@ -48,7 +48,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { Layer.provideMerge(IdAllocator.layer), Layer.provideMerge(McpProviderSessions.layer), Layer.provideMerge( - Layer.mock(CodexAdapterV2.CodexAppServerClientFactory)({ + Layer.mock(CodexAppServerClientFactoryService.CodexAppServerClientFactory)({ open: () => Effect.die("Maintenance resolution must not open a Codex session"), }), ), @@ -136,7 +136,9 @@ it.layer(layerTest)("CodexDriver", (it) => { const serverConfig = yield* ServerConfig.ServerConfig; const sharedHome = NodePath.join(serverConfig.stateDir, "shared-codex-home"); const launches: Array< - Parameters[0] + Parameters< + CodexAppServerClientFactoryService.CodexAppServerClientFactory["Service"]["open"] + >[0] > = []; // Sign-out interrupts an account check that is still resolving the runtime. // Interrupt the two startup checks that way (one is the sign-in listener's); @@ -161,8 +163,8 @@ it.layer(layerTest)("CodexDriver", (it) => { config: { ...CodexDriver.defaultConfig(), setupMode: "managed", homePath: sharedHome }, }).pipe( Effect.provideService( - CodexAdapterV2.CodexAppServerClientFactory, - CodexAdapterV2.CodexAppServerClientFactory.of({ + CodexAppServerClientFactoryService.CodexAppServerClientFactory, + CodexAppServerClientFactoryService.CodexAppServerClientFactory.of({ open: (launch) => Effect.sync(() => launches.push(launch)).pipe( Effect.andThen(Effect.die("The fixture stops after recording the launch")), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 8aa546d103e12..f2449a81661c2 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,3 +1,4 @@ +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; /** * CodexDriver — first concrete `ProviderDriver` in the new per-instance model. * @@ -36,10 +37,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; -import { - createCodexAdapterV2, - type CodexAdapterV2DriverEnv, -} from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import type { CodexAdapterV2DriverEnv } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { ProviderDriverError } from "../Errors.ts"; import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; import { @@ -196,25 +194,33 @@ export const CodexDriver: ProviderDriver snapshot.applyUsageLimits(update) }, - ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Codex orchestration adapter.", - cause, - }), + const orchestrationAdapter = yield* makeLazyAdapter( + instanceId, + DRIVER_KIND, + Effect.promise(() => import("../../orchestration-v2/Adapters/CodexAdapterV2.ts")).pipe( + Effect.flatMap(({ createCodexAdapterV2 }) => + createCodexAdapterV2( + { + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }, + { onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Codex orchestration adapter.", + cause, + }), + ), + ), + ), ), ); diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index 92c461390aea1..5ad36a7167520 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -8,7 +8,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { chatGptModels } from "../CodexChatGptModels.ts"; import { makeCodexManagedRuntime } from "../CodexManagedRuntime.ts"; import { ProviderDriverError } from "../Errors.ts"; -import { createCodexAdapterV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; import { checkCodexProviderStatus, makePendingCodexProvider, @@ -220,18 +220,26 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu const resolveRuntime = runtime.auth.controller.withAccess!(runtime.resolve); // Launch settings resolve per session from the signed-in token. The registry // already wraps openSession in withAccess, so resolve without re-entering it. - const orchestrationAdapter = yield* createCodexAdapterV2(input, { - onUsageLimits: (update) => snapshot.applyUsageLimits(update), - resolveRuntime: runtime.resolve, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER, - instanceId, - detail: "Failed to build Codex orchestration adapter.", - cause, - }), + const orchestrationAdapter = yield* makeLazyAdapter( + instanceId, + DRIVER, + Effect.promise(() => import("../../orchestration-v2/Adapters/CodexAdapterV2.ts")).pipe( + Effect.flatMap(({ createCodexAdapterV2 }) => + createCodexAdapterV2(input, { + onUsageLimits: (update) => snapshot.applyUsageLimits(update), + resolveRuntime: runtime.resolve, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Failed to build Codex orchestration adapter.", + cause, + }), + ), + ), + ), ), ); const nativeGeneration = yield* makeCodexTextGeneration( diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index f312b3d58320e..55a061e0acb38 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -1,7 +1,7 @@ +import * as CodexAppServerClientFactoryService from "./CodexAppServerClientFactory.ts"; +import * as ClaudeAgentSdkQueryRunnerService from "./ClaudeAgentSdkQueryRunner.ts"; import * as Layer from "effect/Layer"; -import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; -import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk"; import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; @@ -9,8 +9,8 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; export type ProviderOrchestrationAdapterInfrastructure = - | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner - | CodexAdapterV2.CodexAppServerClientFactory + | ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner + | CodexAppServerClientFactoryService.CodexAppServerClientFactory | CursorAgentSdk.CursorAgentSdkRunner | CursorSdk.CursorSdk | CursorKeychain.CursorKeychain @@ -23,8 +23,8 @@ export type ProviderOrchestrationAdapterInfrastructure = * Effect layer memoization yields one shared queue. */ export const layer = Layer.mergeAll( - ClaudeAdapterV2.layerQueryRunner, - CodexAdapterV2.layerAppServerClientFactory, + ClaudeAgentSdkQueryRunnerService.layer, + CodexAppServerClientFactoryService.layer, CursorAgentSdk.layer.pipe(Layer.provideMerge(CursorSdk.layer)), CursorKeychain.layer, IdAllocator.layer, diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 532bd602d1453..0216c8d3710cd 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,15 +23,15 @@ import { AcpRegistryDriver, type AcpRegistryDriverEnv, -} from "@t3tools/provider-acp-registry/server"; +} from "@t3tools/provider-acp-registry/server/driver"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; -import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; -import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; -import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; -import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; -import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; +import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server/driver"; +import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server/driver"; +import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server/driver"; +import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server/driver"; +import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server/driver"; import type { AnyProviderDriver, ProviderUsageReaderEnv, diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index a109cf5d75403..5db08f850fc0b 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -46,7 +46,7 @@ import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { mergePathEntries } from "@t3tools/shared/shell"; -import { acpRegistryManagedBinaryDirectories } from "@t3tools/provider-acp-registry/server"; +import { acpRegistryManagedBinaryDirectories } from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index e28e926d3f890..62ce1a05e05ca 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1632,6 +1632,12 @@ describe("UsageService", () => { Effect.provide(layerService({ prefix: "usage-service-interruption-test", home, settings })), ); + // Resolve the first-use import before exercising synchronous scan scheduling. + const warmup = yield* service + .readSummary({ ...WINDOW, sinceDay: WINDOW.untilDay, untilDay: WINDOW.sinceDay }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(warmup)); + let orphanedAt: number | undefined; for (let interruptAt = 1; interruptAt <= 31; interruptAt += 1) { const tasks: Array<() => void> = []; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 2259c1d9f7730..9c2119a9a93ff 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,189 +1,13 @@ -/** - * UsageService - reads every driver's usage history and returns priced usage - * buckets. - * - * Each built-in driver with a `usage` reader contributes its sources, including - * work driven outside T3 Code. A `transcripts` reader names JSONL directories - * that this service streams itself; a `scan` reader reads its own sources. - * - * JSONL transcripts are append-only, so parsed records are memoised per file by - * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm - * scans only reparse files that changed, and a file that merely grew resumes - * from its cached parse position so only the appended bytes are read. - * - * A scan reader with a slow source (an account API) may answer from its own - * cache and mark itself `refreshing` while a background refresh runs; - * `awaitRefresh` waits for that refresh instead. - * - * @module UsageService - */ -import * as NodeOS from "node:os"; - -import { - type ProviderInstanceConfig, - ProviderInstanceId, - USAGE_CONTRACT_VERSION, - type ServerSettings as ServerSettingsValue, - type UsageProviderKind, - type UsageSource, - type UsagePricing, - type UsageSummary, - type UsageSummaryInput, +import type { + UsageSummaryInput, + UsageSummary, + UsagePricing, UsageReadError, } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Cause from "effect/Cause"; -import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; -import { HttpClient, HttpClientResponse } from "effect/http"; - -import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; -import * as ServerConfig from "../config.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import { BUILT_IN_USAGE_DRIVERS, type BuiltInUsageReadersEnv } from "../provider/builtInDrivers.ts"; -import type { ProviderDriver } from "@t3tools/provider-core/server/driver"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import type { - ProviderUsageInstance, - TranscriptUsageFormat, - UsageRecord, -} from "@t3tools/provider-core/server/usage"; -import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; -import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; -import { - listTranscriptFiles, - readDirectoryVolumeId, - readTranscriptRecords, -} from "./usageTranscriptReader.ts"; -import { - decodeScanCache, - dedupeWithinFile, - LEGACY_SCAN_CACHE_FILE_NAME, - makeScanCacheWriter, - pruneScanCache, - SCAN_CACHE_FILE_NAME, - type CachedFile, - type ScanCache, -} from "./usageScanCache.ts"; - -const LITELLM_RATES_URL = - "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; - -/** Rates move rarely; a day-old table keeps the page working offline. */ -const RATES_TTL_MS = 24 * 60 * 60 * 1000; - -/** An explicit refresh ignores the TTL, but not a table fetched this recently. */ -const RATES_REFRESH_FLOOR_MS = 60 * 1000; - -/** - * Files are filtered by mtime before opening. The slack covers a session whose - * last write lands just before local midnight on the window's first day. - */ -const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; -const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; - -/** - * The longest window the UI offers, 90 days, plus its `MTIME_SLACK_MS`, rounded - * up. Older entries are pruned. - */ -const CACHE_RETENTION_DAYS = 92; - -/** Transcripts parsed at once. More gains little once the disk stays busy. */ -const TRANSCRIPT_READ_CONCURRENCY = 4; - -/** The transcript readers, in driver order. */ -const transcriptReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) => - driver.usage?.kind === "transcripts" ? [{ driver, reader: driver.usage }] : [], -); - -/** The scan readers, in driver order. */ -const scanReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) => - driver.usage?.kind === "scan" ? [{ driver, reader: driver.usage }] : [], -); - -/** Transcript formats by provider, for decoding the persisted scan cache. */ -const transcriptFormats = new Map( - transcriptReaders.map(({ reader }) => [reader.provider, reader.format] as const), -); - -/** One transcript directory to scan. */ -interface TranscriptSource { - readonly provider: UsageProviderKind; - readonly format: TranscriptUsageFormat; - readonly dir: string; - readonly volumeId: string; - readonly fileName?: string; -} - -/** On-disk shape of the rate snapshot. */ -const RatesCacheFile = Schema.Struct({ - fetchedAtMs: Schema.Number, - document: Schema.Unknown, -}); -const decodeRatesCache = Schema.decodeUnknownEffect( - Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), -); -const encodeRatesCache = Schema.encodeEffect( - Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), -); - -/** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */ -const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); -const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); -const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson); -const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String }); - -/** Whether `a` read a later state of its file than `b`. Transcripts only grow. */ -function isLaterRead(a: CachedFile, b: CachedFile): boolean { - return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size); -} - -/** Providers whose format lets one session's records appear in several files. */ -const sharedSessionProviders: ReadonlySet = new Set( - transcriptReaders.flatMap(({ reader }) => - reader.format.sharedSessionsAcrossFiles ? [reader.provider] : [], - ), -); - -/** - * Sessions of `sharedSessionProviders` with records in more than one file, - * such as a rollout that moved after it was read. Only these need cross-file - * dedupe keys: within one file the occurrence count already keeps every key - * unique, so keying the rest would only build and hash a string for each of - * their records. - */ -function sharedSessions( - files: readonly { readonly records: readonly UsageRecord[] }[], -): ReadonlySet { - const firstFile = new Map(); - const shared = new Set(); - for (const [index, file] of files.entries()) { - let previous = ""; - for (const { provider, sessionId } of file.records) { - if (!sharedSessionProviders.has(provider) || sessionId === previous || sessionId.length === 0) - continue; - previous = sessionId; - const first = firstFile.get(sessionId); - if (first === undefined) firstFile.set(sessionId, index); - else if (first !== index) shared.add(sessionId); - } - } - return shared; -} -const decodeCachedSources = Schema.decodeUnknownOption( - Schema.Struct({ sources: Schema.Record(Schema.String, CachedSource) }), -); +import { cacheInOwnerScope } from "@t3tools/shared/lazyService"; export class UsageService extends Context.Service< UsageService, @@ -194,743 +18,15 @@ export class UsageService extends Context.Service< } >()("t3/usage/UsageService") {} -const EMPTY_PRICING: UsagePricing = { - status: "unavailable", - source: LITELLM_RATES_URL, - fetchedAt: null, - knownModels: 0, -}; - -/** Empty summary, for suites that only need the RPC surface to resolve. */ -const layerTest = Layer.succeed( - UsageService, - UsageService.of({ - readSummary: (input) => - Effect.succeed({ - contractVersion: USAGE_CONTRACT_VERSION, - readAt: "1970-01-01T00:00:00.000Z", - timeZone: input.timeZone, - sinceDay: input.sinceDay, - untilDay: input.untilDay, - buckets: [], - sources: [], - pricing: EMPTY_PRICING, - scanDurationMs: 0, - }), - refreshRates: Effect.succeed(EMPTY_PRICING), - }), -); - export const make = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const config = yield* ServerConfig.ServerConfig; - const settingsService = yield* ServerSettings.ServerSettingsService; - const httpClient = yield* HttpClient.HttpClient; - const hostEnvironment = yield* HostProcess.Environment; - // The readers yield their own services; scans run them against this context. - const readerContext = yield* Effect.context(); - - const fileCache: ScanCache = new Map(); - const sourceCache = new Map(); - let cacheDirty = false; - const isWithinDirectory = (filePath: string, dir: string) => { - const relative = path.relative(dir, filePath); - return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); - }; - - const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); - const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); - const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME); - const writeCacheFile = (filePath: string, contents: string) => - writeFileStringAtomically({ filePath, contents }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); - let rates: RateTable = new Map(); - let ratesFetchedAtMs: number | null = null; - let ratesStatus: UsagePricing["status"] = "unavailable"; - // One fetch at a time. A burst of refreshes from several clients waits on - // the first fetch and then sees a table young enough to skip its own. - const ratesLock = yield* Semaphore.make(1); - - const pricing = (): UsagePricing => ({ - status: ratesStatus, - source: LITELLM_RATES_URL, - fetchedAt: - ratesFetchedAtMs === null ? null : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), - knownModels: rates.size, - }); - - /** - * Loads the LiteLLM rate table, preferring a fresh copy and falling back to - * the on-disk snapshot. With neither, every model reports as unpriced rather - * than the page failing. `force` refetches inside the TTL so a model that - * LiteLLM added since the last fetch gets priced now. - */ - const loadRates = Effect.fn("UsageService.loadRates")(function* (force: boolean) { - const now = yield* Clock.currentTimeMillis; - const maxAgeMs = force ? RATES_REFRESH_FLOOR_MS : RATES_TTL_MS; - if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < maxAgeMs) return; - - if (ratesFetchedAtMs === null) { - const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe( - Effect.flatMap((raw) => decodeRatesCache(raw)), - Effect.catchCause(() => Effect.succeed(null)), - ); - if (fromDisk !== null) { - const parsed = parseRateTable(fromDisk.document); - if (parsed.size > 0) { - rates = parsed; - ratesFetchedAtMs = fromDisk.fetchedAtMs; - ratesStatus = "cached"; - if (now - fromDisk.fetchedAtMs < maxAgeMs) return; - } - } - } - - const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap((response) => response.json), - Effect.timeout(10_000), - Effect.catchCause(() => Effect.succeed(null)), - ); - if (fetched === null) { - // The refresh failed; whatever we are serving is now past its TTL and - // must not keep claiming to be fresh. - if (rates.size > 0) ratesStatus = "cached"; - return; - } - - const parsed = parseRateTable(fetched); - if (parsed.size === 0) return; - - rates = parsed; - ratesFetchedAtMs = now; - ratesStatus = "fresh"; - - yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe( - Effect.flatMap((contents) => writeCacheFile(ratesCachePath, contents)), - Effect.ignoreCause, - ); - }); - - const ensureRates = (force: boolean) => ratesLock.withPermit(loadRates(force)); - - const refreshRates = ensureRates(true).pipe( - Effect.map(pricing), - Effect.withSpan("UsageService.refreshRates"), - ); - - // A settings failure must not silently discard custom rates or transcript homes. - const readSettings = settingsService.getSettings.pipe( - Effect.catchCause( - (cause) => - new UsageReadError({ - reason: "scanFailed", - detail: "Server settings could not be read.", - cause: Cause.squash(cause), - }), - ), - ); - - /** - * Every instance of `driver` with its decoded config, or none when it does - * not decode. Disabled accounts still have history. An unconfigured default - * slot runs with default config, just as it does in the provider registry. - */ - const usageInstances = Effect.fn("UsageService.usageInstances")(function* ( - driver: ProviderDriver, - settings: ServerSettingsValue, - ) { - const entries: Array< - readonly [ProviderInstanceId, Pick, boolean] - > = Object.entries(settings.providerInstances) - .filter(([, instance]) => instance.driver === driver.driverKind) - .map(([id, instance]) => [ProviderInstanceId.make(id), instance, true] as const); - if (!Object.hasOwn(settings.providerInstances, driver.driverKind)) { - entries.push([ProviderInstanceId.make(driver.driverKind), {}, false]); - } - const decodeConfig = Schema.decodeUnknownOption(driver.configSchema); - return yield* Effect.forEach( - entries, - Effect.fnUntraced(function* ([instanceId, instance, configured]) { - const instanceConfig: ProviderUsageInstance = { - instanceId, - config: Option.getOrUndefined(decodeConfig(instance.config ?? {})), - environment: yield* mergeProviderInstanceEnvironment( - instance.environment, - hostEnvironment, - ), - configured, - }; - return instanceConfig; - }), - ); - }); - - /** Resolves every transcript directory the usage readers point at. */ - const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( - settings: ServerSettingsValue, - retentionCutoffMs: number, - ) { - const dirs: Array = []; - const seen = new Set(); - for (const { driver, reader } of transcriptReaders) { - const { provider, format } = reader; - const directories = yield* Effect.forEach( - yield* usageInstances(driver, settings), - (instance) => reader.directories(instance), - ); - for (const { dir: directory, fileName } of directories.flat()) { - const sourceKey = provider + "\0" + directory; - const previous = sourceCache.get(sourceKey); - // Keep canonical paths and source fingerprints stable after root cleanup, - // including aliases and clients merging pre-cleanup environment summaries. - const dir = yield* fileSystem - .realPath(directory) - .pipe(Effect.orElseSucceed(() => previous?.dir ?? directory)); - const currentVolumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); - const hasRetainedHistory = fileCache - .entries() - .some( - ([filePath, entry]) => - entry.provider === provider && - entry.mtimeMs >= retentionCutoffMs && - entry.records.length + entry.tailRecords.length > 0 && - isWithinDirectory(filePath, dir), - ); - // A recreated directory still reports the retained history under its old identity. - const volumeId = - previous?.dir === dir && (hasRetainedHistory || !currentVolumeId) - ? previous.volumeId || currentVolumeId - : currentVolumeId; - if (previous?.dir !== dir || previous.volumeId !== volumeId) { - sourceCache.set(sourceKey, { dir, volumeId }); - cacheDirty = true; - } - const key = `${provider}\0${dir}`; - if (seen.has(key)) continue; - seen.add(key); - dirs.push({ - provider, - format, - dir, - volumeId, - ...(fileName === undefined ? {} : { fileName }), - }); - } - } - return dirs; - }); - - /** - * Loads the persisted scan cache exactly once per process. - * - * `Effect.cached` makes concurrent first readers await the same load rather - * than each seeing a "loaded" flag set before the read finished and cold - * scanning against an empty cache. - */ - const ensureScanCacheLoaded = yield* Effect.cached( - Effect.gen(function* () { - const readDocument = (filePath: string) => - fileSystem.readFileString(filePath).pipe( - Effect.flatMap((raw) => decodeScanCacheFile(raw)), - Effect.catchCause(() => Effect.succeed(null)), - ); - let document = yield* readDocument(scanCachePath); - if (document === null) { - document = yield* readDocument(legacyScanCachePath); - // Write the migrated cache to its own file on the next scan. - cacheDirty = document !== null; - } - if (document === null) return; - for (const [path, entry] of decodeScanCache(document, transcriptFormats)) { - fileCache.set(path, entry); - } - const sources = decodeCachedSources(document); - if (Option.isSome(sources)) { - for (const [key, source] of Object.entries(sources.value.sources)) - sourceCache.set(key, source); - } - }), - ); - - const writeScanCache = makeScanCacheWriter(); - // Scans with different windows can finish together; serializing the writes - // keeps an older snapshot from landing after a newer one. - const persistLock = yield* Semaphore.make(1); - - // The dirty flag is cleared before encoding, so a change while the write is - // in flight marks it dirty again. A failed write restores the flag, so the - // next persist retries instead of leaving disk stale. A cache we cannot - // write is a slower next start, not a failed read. - const persistCaches = Effect.gen(function* () { - if (cacheDirty) { - cacheDirty = false; - yield* Effect.sync(() => - writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }), - ).pipe( - Effect.flatMap((contents) => writeCacheFile(scanCachePath, contents)), - Effect.catchCause(() => - Effect.sync(() => { - cacheDirty = true; - }), - ), - ); - } - }).pipe(persistLock.withPermit, Effect.withSpan("UsageService.persistCaches")); - - const pendingPersists = new Set>(); - /** Writes dirty caches in the background, after the summary that dirtied them answers. */ - const schedulePersist = Effect.forkDetach(persistCaches).pipe( - Effect.map((fiber) => { - pendingPersists.add(fiber); - fiber.addObserver(() => pendingPersists.delete(fiber)); - }), - ); - /** Waits for every write scheduled so far, as a restart would need. */ - const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe( - Effect.asVoid, + const service = yield* cacheInOwnerScope( + Effect.promise(() => import("./UsageServiceLive.ts")).pipe(Effect.flatMap((live) => live.make)), ); - // A write still running when the service shuts down finishes first, so the - // next start does not lose the last scan. - yield* Effect.addFinalizer(() => awaitPersisted); - - /** - * Parses one transcript, reusing the cached result when it is unchanged. - * - * A file that only grew re-parses from the cached position, so an actively - * written multi-hundred-megabyte rollout costs its appended bytes per scan - * rather than a full re-read. The reader verifies the position's guard bytes - * and silently restarts from byte 0 when they no longer match. - * - * A fresh parse comes back as `update` for the caller to cache, with the - * entry it was built from. Reads run concurrently, and the caller stores - * updates in walk order rather than completion order: saved records of - * deleted transcripts aggregate in cache order, where the first copy of a - * duplicate wins. - */ - const readFileRecords = ( - filePath: string, - size: number, - mtimeMs: number, - provider: UsageProviderKind, - format: TranscriptUsageFormat, - ): Effect.Effect<{ - readonly records: readonly UsageRecord[]; - readonly failed?: true; - readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; - }> => - Effect.gen(function* () { - const cached = fileCache.get(filePath); - // Provider is part of the identity: if both providers were ever pointed - // at one directory, a hit parsed by the other parser must not be reused. - if ( - cached && - cached.size === size && - cached.mtimeMs === mtimeMs && - cached.provider === provider - ) { - return { - records: - cached.tailRecords.length === 0 - ? cached.records - : [...cached.records, ...cached.tailRecords], - }; - } - - // Only a strictly grown file may resume. Same size with a new mtime, or - // a shrunken file, means rewritten content; re-parse it whole. - const resumeFrom = - cached !== undefined && cached.provider === provider && size > cached.size - ? cached.position - : undefined; - - const parsed = yield* Effect.promise(() => - readTranscriptRecords(filePath, format, resumeFrom), - ); - // A read failure is not an empty transcript: caching it under this - // (size, mtime) would silently drop the file's usage until it changes. - if (parsed === null) - return { - records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], - failed: true, - }; - - // Stored already de-duplicated within the file, which is 99% of all - // duplicates. The aggregator still runs the cross-file dedupe pass. One - // seen set spans the cached base, the new lines, and the tail so a - // resumed parse dedupes exactly like a full one. - const base = parsed.resumed && cached !== undefined ? cached.records : []; - const seen = new Set(); - const records = dedupeWithinFile([...base, ...parsed.records], seen); - const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); - - return { - records: tailRecords.length === 0 ? records : [...records, ...tailRecords], - update: { - entry: { size, mtimeMs, provider, records, tailRecords, position: parsed.position }, - replaces: cached, - }, - }; - }); - - /** One provider directory's walk and parse, before rates are involved. */ - interface ScannedDir { - readonly provider: UsageProviderKind; - readonly dir: string; - readonly volumeId: string; - readonly hostId?: string; - readonly status?: UsageSource["status"]; - readonly message?: string; - readonly action?: UsageSource["action"]; - /** Parsed records per file, or `null` when the directory does not exist. */ - readonly files: - | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] - | null; - /** Answered from a cache while a refresh runs. */ - readonly refreshing?: true; - } - - const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* ( - source: TranscriptSource, - windowStartMs: number, - ) { - const { provider, format, dir, volumeId, fileName } = source; - const exists = yield* fileSystem - .exists(dir) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - if (!exists) return { provider, dir, volumeId, files: null } satisfies ScannedDir; - const { files, failedPaths } = yield* Effect.promise(() => - listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), - ); - // A cold parse waits on disk reads, so a few files in flight read - // close to twice as fast. Results keep walk order. - const read = yield* Effect.forEach( - files, - (file) => - readFileRecords(file.path, file.size, file.mtimeMs, provider, format).pipe( - Effect.map((result) => ({ path: file.path, ...result })), - ), - { concurrency: TRANSCRIPT_READ_CONCURRENCY }, - ); - const parsedFiles = read.map(({ path, records, update }) => { - if (update === undefined) return { path, records }; - // A scan of another window may have cached its own read of this file - // meanwhile. Then keep whichever read saw the later file, so a slower - // scan never replaces newer usage with older. - const current = fileCache.get(path); - if ( - current === update.replaces || - current === undefined || - !isLaterRead(current, update.entry) - ) { - fileCache.set(path, update.entry); - cacheDirty = true; - } - return { path, records }; - }); - // Unread files keep their cached usage, but the total may be short. - const unread = failedPaths + read.filter((file) => file.failed).length; - return { - provider, - dir, - volumeId, - files: parsedFiles, - ...(unread > 0 - ? { - status: "partial", - message: `${unread} transcript path(s) could not be read; usage may be incomplete.`, - } - : {}), - } satisfies ScannedDir; - }); - - const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( - windowStartMs: number, - settings: ServerSettingsValue, - retentionCutoffMs: number, - awaitRefresh: boolean, - ) { - const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( - Effect.provideContext(readerContext), - ); - - const scans = Effect.forEach( - scanReaders, - ({ driver, reader }) => - usageInstances(driver, settings) - .pipe( - Effect.flatMap((instances) => - reader.scan({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }), - ), - ) - .pipe( - Effect.flatMap((sources) => - Effect.forEach(sources, ({ volumeId, ...source }) => - Effect.map( - volumeId === undefined - ? Effect.promise(() => readDirectoryVolumeId(source.dir)) - : Effect.succeed(volumeId), - (resolved): ScannedDir => ({ - ...source, - provider: reader.provider, - volumeId: resolved, - }), - ), - ), - ), - Effect.provideContext(readerContext), - ), - { concurrency: "unbounded" }, - ); - - // Independent sources scan together. Transcript directories go one at a - // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result - // keeps this order, since aggregation keeps the first copy of a duplicate. - const [transcripts, scanDirs] = yield* Effect.all( - [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans], - { concurrency: "unbounded" }, - ); - const scanned: readonly ScannedDir[] = [...transcripts, ...scanDirs.flat()]; - return scanned; - }); - - const scanSummary = Effect.fn("UsageService.scanSummary")(function* ( - input: UsageSummaryInput, - settings: ServerSettingsValue, - ) { - if (input.sinceDay > input.untilDay) { - return yield* new UsageReadError({ - reason: "invalidWindow", - detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`, - }); - } - - let hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null; - if (input.resolution === "hour") { - const sinceTime = - input.sinceTime === undefined ? Option.none() : DateTime.make(input.sinceTime); - const untilTime = - input.untilTime === undefined ? Option.none() : DateTime.make(input.untilTime); - if (Option.isNone(sinceTime) || Option.isNone(untilTime)) { - return yield* new UsageReadError({ - reason: "invalidWindow", - detail: "Hourly usage requires valid sinceTime and untilTime instants", - }); - } - const sinceTimeMs = DateTime.toEpochMillis(sinceTime.value); - const untilTimeMs = DateTime.toEpochMillis(untilTime.value); - const durationMs = untilTimeMs - sinceTimeMs; - if (durationMs <= 0 || durationMs > MAX_HOURLY_WINDOW_MS) { - return yield* new UsageReadError({ - reason: "invalidWindow", - detail: "Hourly usage window must be greater than zero and at most 24 hours", - }); - } - hourlyWindow = { sinceTimeMs, untilTimeMs }; - } - - const startedAtMs = yield* Clock.currentTimeMillis; - yield* ensureScanCacheLoaded; - - const hostId = NodeOS.hostname(); - const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); - if (Option.isNone(windowStart)) { - return yield* new UsageReadError({ - reason: "invalidWindow", - detail: `sinceDay '${input.sinceDay}' is not a valid date`, - }); - } - const windowStartMs = - (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; - - const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; - - // Pricing only matters once records are aggregated, so the rate table - // loads while transcripts stream instead of gating them: a cold rates - // fetch on a slow network no longer delays the scan by its own timeout. - const [, scannedDirs] = yield* Effect.all( - [ - ensureRates(false), - collectDirs(windowStartMs, settings, retentionCutoffMs, input.awaitRefresh === true), - ], - { concurrency: 2 }, - ); - - const aggregator = new UsageAggregator({ - timeZone: input.timeZone, - sinceDay: input.sinceDay, - untilDay: input.untilDay, - resolution: input.resolution ?? "day", - ...hourlyWindow, - rates, - priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), - modelAliases: resolveModelAliases(settings.usageModelAliases), - }); - - const sources: UsageSource[] = []; - - // Cleanup may remove transcripts, but the usage we already saved still - // contributes to its source through the normal aggregation and dedupe - // path. Like the walk, skip files last written before the window: they - // cannot hold records inside it. - const retainedSinceMs = Math.max(windowStartMs, retentionCutoffMs); - const filesByDir = scannedDirs.map(({ provider, dir, files }) => { - const retainedFiles = [...(files ?? [])]; - const livePaths = new Set(retainedFiles.map((file) => file.path)); - for (const [filePath, entry] of fileCache) { - if ( - entry.provider !== provider || - entry.mtimeMs < retainedSinceMs || - livePaths.has(filePath) || - !isWithinDirectory(filePath, dir) - ) - continue; - retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] }); - } - return retainedFiles; - }); - const crossFileSessions = sharedSessions(filesByDir.flat()); - - for (const [ - index, - { provider, dir, volumeId, files, status, message, action, refreshing, hostId: sourceHostId }, - ] of scannedDirs.entries()) { - let scannedFiles = 0; - let skippedFiles = 0; - // Distinct per directory. Buckets carry per-cell session counts, but a - // session spans days and models, so clients total this figure instead. - const sessionIds = new Set(); - - for (const file of filesByDir[index] ?? []) { - if (file.records.length === 0) { - skippedFiles += 1; - continue; - } - scannedFiles += 1; - const eventOccurrences = new Map(); - for (const record of file.records) { - let usageRecord = record; - if ( - sharedSessionProviders.has(record.provider) && - crossFileSessions.has(record.sessionId) - ) { - // Match moved copies without collapsing repeated equal events - // within one file (timestamps can have only second precision). - // Only sessions seen in several files can have a copy to match. - const key = encodeUsageRecordKey([ - record.provider, - record.sessionId, - record.timestampMs, - record.model, - record.totals, - ]); - const occurrence = (eventOccurrences.get(key) ?? 0) + 1; - eventOccurrences.set(key, occurrence); - usageRecord = { ...record, dedupeKey: key + ":" + occurrence }; - } - // Only sessions contributing in-window count; the mtime slack can - // admit boundary files whose records fall outside the range. - if (aggregator.add(usageRecord, dir) && record.sessionId.length > 0) { - sessionIds.add(record.sessionId); - } - } - } - - sources.push({ - fingerprint: { hostId: sourceHostId ?? hostId, provider, resolvedHomePath: dir, volumeId }, - // Clients exclude missing sources, so saved records remain an available source. - status: files === null && scannedFiles === 0 ? "missing" : (status ?? "ok"), - scannedFiles, - skippedFiles, - malformedRecords: 0, - distinctSessions: sessionIds.size, - message: - message ?? (files === null ? "No transcript directory on this environment." : null), - ...(action ? { action } : {}), - ...(refreshing ? { refreshing } : {}), - }); - } - - const pruned = pruneScanCache(fileCache, retentionCutoffMs); - if (pruned > 0) cacheDirty = true; - - const aggregated = aggregator.finish(); - const readAt = yield* DateTime.now; - const finishedAtMs = yield* Clock.currentTimeMillis; - - return { - contractVersion: USAGE_CONTRACT_VERSION, - readAt: DateTime.formatIso(readAt), - timeZone: input.timeZone, - sinceDay: input.sinceDay, - untilDay: input.untilDay, - buckets: aggregated.buckets, - sources, - pricing: pricing(), - scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), - } satisfies UsageSummary; - }); - - /** - * In-flight scans by window and settings, so concurrent identical requests (the usage - * page open on two clients at once) share one scan instead of racing over - * the same corpus twice. Readers read settings of their own, so a scan is - * shared only under the same settings snapshot. - */ - const inflightScans = new Map>(); - - const scanKey = (input: UsageSummaryInput, settings: ServerSettingsValue): string => - JSON.stringify([ - input.timeZone, - input.sinceDay, - input.untilDay, - input.resolution ?? "day", - input.sinceTime ?? null, - input.untilTime ?? null, - settings, - // A waiting read must never share a scan that answers with `refreshing`. - input.awaitRefresh === true, - ]); - - const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { - const settings = yield* readSettings; - const key = scanKey(input, settings); - const deferred = yield* Effect.uninterruptible( - Effect.gen(function* () { - const existing = inflightScans.get(key); - if (existing !== undefined) return existing; - - // Enrollment and detached-fiber creation must be atomic. Otherwise a - // canceled first caller can leave a Deferred with no scan to finish it. - const created = Deferred.makeUnsafe(); - inflightScans.set(key, created); - // Detached so one departing client cannot tear the scan out from under - // the fibers awaiting it; a finished scan warms the cache either way. - // The cache write is registered before the waiters resume, so they - // can await it, but its fiber starts after they have the summary. - yield* scanSummary(input, settings).pipe( - Effect.onExit((exit) => - Effect.sync(() => inflightScans.delete(key)).pipe( - Effect.andThen(Exit.isSuccess(exit) ? schedulePersist : Effect.void), - Effect.andThen(Deferred.done(created, exit)), - ), - ), - Effect.forkDetach, - ); - return created; - }), - ); - // Waiting stays interruptible. The detached scan continues for other - // callers and still warms the cache if this caller leaves. - return yield* Deferred.await(deferred); - }); - - // `awaitPersisted` is outside the service interface: tests use it to restart - // against what a previous instance wrote. - return { readSummary, refreshRates, awaitPersisted } as const; + return { + readSummary: (input: UsageSummaryInput) => + service.pipe(Effect.flatMap((value) => value.readSummary(input))), + refreshRates: service.pipe(Effect.flatMap((value) => value.refreshRates)), + awaitPersisted: service.pipe(Effect.flatMap((value) => value.awaitPersisted)), + }; }); - export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/UsageServiceLive.ts b/apps/server/src/usage/UsageServiceLive.ts new file mode 100644 index 0000000000000..e2b04f3c22e3f --- /dev/null +++ b/apps/server/src/usage/UsageServiceLive.ts @@ -0,0 +1,896 @@ +/** + * UsageService - reads every driver's usage history and returns priced usage + * buckets. + * + * Each built-in driver with a `usage` reader contributes its sources, including + * work driven outside T3 Code. A `transcripts` reader names JSONL directories + * that this service streams itself; a `scan` reader reads its own sources. + * + * JSONL transcripts are append-only, so parsed records are memoised per file by + * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm + * scans only reparse files that changed, and a file that merely grew resumes + * from its cached parse position so only the appended bytes are read. + * + * A scan reader with a slow source (an account API) may answer from its own + * cache and mark itself `refreshing` while a background refresh runs; + * `awaitRefresh` waits for that refresh instead. + * + * @module UsageService + */ +import * as NodeOS from "node:os"; + +import { + type ProviderInstanceConfig, + ProviderInstanceId, + USAGE_CONTRACT_VERSION, + type ServerSettings as ServerSettingsValue, + type UsageProviderKind, + type UsageSource, + type UsagePricing, + type UsageSummary, + type UsageSummaryInput, + UsageReadError, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { BUILT_IN_USAGE_DRIVERS, type BuiltInUsageReadersEnv } from "../provider/builtInDrivers.ts"; +import type { ProviderDriver } from "@t3tools/provider-core/server/driver"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import type { + ProviderUsageInstance, + TranscriptUsageFormat, + UsageRecord, +} from "@t3tools/provider-core/server/usage"; +import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; +import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; +import { + listTranscriptFiles, + readDirectoryVolumeId, + readTranscriptRecords, +} from "./usageTranscriptReader.ts"; +import { + decodeScanCache, + dedupeWithinFile, + LEGACY_SCAN_CACHE_FILE_NAME, + makeScanCacheWriter, + pruneScanCache, + SCAN_CACHE_FILE_NAME, + type CachedFile, + type ScanCache, +} from "./usageScanCache.ts"; + +const LITELLM_RATES_URL = + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; + +/** Rates move rarely; a day-old table keeps the page working offline. */ +const RATES_TTL_MS = 24 * 60 * 60 * 1000; + +/** An explicit refresh ignores the TTL, but not a table fetched this recently. */ +const RATES_REFRESH_FLOOR_MS = 60 * 1000; + +/** + * Files are filtered by mtime before opening. The slack covers a session whose + * last write lands just before local midnight on the window's first day. + */ +const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; +const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; + +/** + * The longest window the UI offers, 90 days, plus its `MTIME_SLACK_MS`, rounded + * up. Older entries are pruned. + */ +const CACHE_RETENTION_DAYS = 92; + +/** Transcripts parsed at once. More gains little once the disk stays busy. */ +const TRANSCRIPT_READ_CONCURRENCY = 4; + +/** The transcript readers, in driver order. */ +const transcriptReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) => + driver.usage?.kind === "transcripts" ? [{ driver, reader: driver.usage }] : [], +); + +/** The scan readers, in driver order. */ +const scanReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) => + driver.usage?.kind === "scan" ? [{ driver, reader: driver.usage }] : [], +); + +/** Transcript formats by provider, for decoding the persisted scan cache. */ +const transcriptFormats = new Map( + transcriptReaders.map(({ reader }) => [reader.provider, reader.format] as const), +); + +/** One transcript directory to scan. */ +interface TranscriptSource { + readonly provider: UsageProviderKind; + readonly format: TranscriptUsageFormat; + readonly dir: string; + readonly volumeId: string; + readonly fileName?: string; +} + +/** On-disk shape of the rate snapshot. */ +const RatesCacheFile = Schema.Struct({ + fetchedAtMs: Schema.Number, + document: Schema.Unknown, +}); +const decodeRatesCache = Schema.decodeUnknownEffect( + Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), +); +const encodeRatesCache = Schema.encodeEffect( + Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), +); + +/** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */ +const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); +const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); +const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson); +const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String }); + +/** Whether `a` read a later state of its file than `b`. Transcripts only grow. */ +function isLaterRead(a: CachedFile, b: CachedFile): boolean { + return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size); +} + +/** Providers whose format lets one session's records appear in several files. */ +const sharedSessionProviders: ReadonlySet = new Set( + transcriptReaders.flatMap(({ reader }) => + reader.format.sharedSessionsAcrossFiles ? [reader.provider] : [], + ), +); + +/** + * Sessions of `sharedSessionProviders` with records in more than one file, + * such as a rollout that moved after it was read. Only these need cross-file + * dedupe keys: within one file the occurrence count already keeps every key + * unique, so keying the rest would only build and hash a string for each of + * their records. + */ +function sharedSessions( + files: readonly { readonly records: readonly UsageRecord[] }[], +): ReadonlySet { + const firstFile = new Map(); + const shared = new Set(); + for (const [index, file] of files.entries()) { + let previous = ""; + for (const { provider, sessionId } of file.records) { + if (!sharedSessionProviders.has(provider) || sessionId === previous || sessionId.length === 0) + continue; + previous = sessionId; + const first = firstFile.get(sessionId); + if (first === undefined) firstFile.set(sessionId, index); + else if (first !== index) shared.add(sessionId); + } + } + return shared; +} +const decodeCachedSources = Schema.decodeUnknownOption( + Schema.Struct({ sources: Schema.Record(Schema.String, CachedSource) }), +); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + const hostEnvironment = yield* HostProcess.Environment; + // The readers yield their own services; scans run them against this context. + const readerContext = yield* Effect.context(); + + const fileCache: ScanCache = new Map(); + const sourceCache = new Map(); + let cacheDirty = false; + const isWithinDirectory = (filePath: string, dir: string) => { + const relative = path.relative(dir, filePath); + return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); + }; + + const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); + const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); + const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME); + const writeCacheFile = (filePath: string, contents: string) => + writeFileStringAtomically({ filePath, contents }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + let rates: RateTable = new Map(); + let ratesFetchedAtMs: number | null = null; + let ratesStatus: UsagePricing["status"] = "unavailable"; + // One fetch at a time. A burst of refreshes from several clients waits on + // the first fetch and then sees a table young enough to skip its own. + const ratesLock = yield* Semaphore.make(1); + + const pricing = (): UsagePricing => ({ + status: ratesStatus, + source: LITELLM_RATES_URL, + fetchedAt: + ratesFetchedAtMs === null ? null : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), + knownModels: rates.size, + }); + + /** + * Loads the LiteLLM rate table, preferring a fresh copy and falling back to + * the on-disk snapshot. With neither, every model reports as unpriced rather + * than the page failing. `force` refetches inside the TTL so a model that + * LiteLLM added since the last fetch gets priced now. + */ + const loadRates = Effect.fn("UsageService.loadRates")(function* (force: boolean) { + const now = yield* Clock.currentTimeMillis; + const maxAgeMs = force ? RATES_REFRESH_FLOOR_MS : RATES_TTL_MS; + if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < maxAgeMs) return; + + if (ratesFetchedAtMs === null) { + const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe( + Effect.flatMap((raw) => decodeRatesCache(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fromDisk !== null) { + const parsed = parseRateTable(fromDisk.document); + if (parsed.size > 0) { + rates = parsed; + ratesFetchedAtMs = fromDisk.fetchedAtMs; + ratesStatus = "cached"; + if (now - fromDisk.fetchedAtMs < maxAgeMs) return; + } + } + } + + const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeout(10_000), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fetched === null) { + // The refresh failed; whatever we are serving is now past its TTL and + // must not keep claiming to be fresh. + if (rates.size > 0) ratesStatus = "cached"; + return; + } + + const parsed = parseRateTable(fetched); + if (parsed.size === 0) return; + + rates = parsed; + ratesFetchedAtMs = now; + ratesStatus = "fresh"; + + yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe( + Effect.flatMap((contents) => writeCacheFile(ratesCachePath, contents)), + Effect.ignoreCause, + ); + }); + + const ensureRates = (force: boolean) => ratesLock.withPermit(loadRates(force)); + + const refreshRates = ensureRates(true).pipe( + Effect.map(pricing), + Effect.withSpan("UsageService.refreshRates"), + ); + + // A settings failure must not silently discard custom rates or transcript homes. + const readSettings = settingsService.getSettings.pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Server settings could not be read.", + cause: Cause.squash(cause), + }), + ), + ); + + /** + * Every instance of `driver` with its decoded config, or none when it does + * not decode. Disabled accounts still have history. An unconfigured default + * slot runs with default config, just as it does in the provider registry. + */ + const usageInstances = Effect.fn("UsageService.usageInstances")(function* ( + driver: ProviderDriver, + settings: ServerSettingsValue, + ) { + const entries: Array< + readonly [ProviderInstanceId, Pick, boolean] + > = Object.entries(settings.providerInstances) + .filter(([, instance]) => instance.driver === driver.driverKind) + .map(([id, instance]) => [ProviderInstanceId.make(id), instance, true] as const); + if (!Object.hasOwn(settings.providerInstances, driver.driverKind)) { + entries.push([ProviderInstanceId.make(driver.driverKind), {}, false]); + } + const decodeConfig = Schema.decodeUnknownOption(driver.configSchema); + return yield* Effect.forEach( + entries, + Effect.fnUntraced(function* ([instanceId, instance, configured]) { + const instanceConfig: ProviderUsageInstance = { + instanceId, + config: Option.getOrUndefined(decodeConfig(instance.config ?? {})), + environment: yield* mergeProviderInstanceEnvironment( + instance.environment, + hostEnvironment, + ), + configured, + }; + return instanceConfig; + }), + ); + }); + + /** Resolves every transcript directory the usage readers point at. */ + const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( + settings: ServerSettingsValue, + retentionCutoffMs: number, + ) { + const dirs: Array = []; + const seen = new Set(); + for (const { driver, reader } of transcriptReaders) { + const { provider, format } = reader; + const directories = yield* Effect.forEach( + yield* usageInstances(driver, settings), + (instance) => reader.directories(instance), + ); + for (const { dir: directory, fileName } of directories.flat()) { + const sourceKey = provider + "\0" + directory; + const previous = sourceCache.get(sourceKey); + // Keep canonical paths and source fingerprints stable after root cleanup, + // including aliases and clients merging pre-cleanup environment summaries. + const dir = yield* fileSystem + .realPath(directory) + .pipe(Effect.orElseSucceed(() => previous?.dir ?? directory)); + const currentVolumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const hasRetainedHistory = fileCache + .entries() + .some( + ([filePath, entry]) => + entry.provider === provider && + entry.mtimeMs >= retentionCutoffMs && + entry.records.length + entry.tailRecords.length > 0 && + isWithinDirectory(filePath, dir), + ); + // A recreated directory still reports the retained history under its old identity. + const volumeId = + previous?.dir === dir && (hasRetainedHistory || !currentVolumeId) + ? previous.volumeId || currentVolumeId + : currentVolumeId; + if (previous?.dir !== dir || previous.volumeId !== volumeId) { + sourceCache.set(sourceKey, { dir, volumeId }); + cacheDirty = true; + } + const key = `${provider}\0${dir}`; + if (seen.has(key)) continue; + seen.add(key); + dirs.push({ + provider, + format, + dir, + volumeId, + ...(fileName === undefined ? {} : { fileName }), + }); + } + } + return dirs; + }); + + /** + * Loads the persisted scan cache exactly once per process. + * + * `Effect.cached` makes concurrent first readers await the same load rather + * than each seeing a "loaded" flag set before the read finished and cold + * scanning against an empty cache. + */ + const ensureScanCacheLoaded = yield* Effect.cached( + Effect.gen(function* () { + const readDocument = (filePath: string) => + fileSystem.readFileString(filePath).pipe( + Effect.flatMap((raw) => decodeScanCacheFile(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + let document = yield* readDocument(scanCachePath); + if (document === null) { + document = yield* readDocument(legacyScanCachePath); + // Write the migrated cache to its own file on the next scan. + cacheDirty = document !== null; + } + if (document === null) return; + for (const [path, entry] of decodeScanCache(document, transcriptFormats)) { + fileCache.set(path, entry); + } + const sources = decodeCachedSources(document); + if (Option.isSome(sources)) { + for (const [key, source] of Object.entries(sources.value.sources)) + sourceCache.set(key, source); + } + }), + ); + + const writeScanCache = makeScanCacheWriter(); + // Scans with different windows can finish together; serializing the writes + // keeps an older snapshot from landing after a newer one. + const persistLock = yield* Semaphore.make(1); + + // The dirty flag is cleared before encoding, so a change while the write is + // in flight marks it dirty again. A failed write restores the flag, so the + // next persist retries instead of leaving disk stale. A cache we cannot + // write is a slower next start, not a failed read. + const persistCaches = Effect.gen(function* () { + if (cacheDirty) { + cacheDirty = false; + yield* Effect.sync(() => + writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }), + ).pipe( + Effect.flatMap((contents) => writeCacheFile(scanCachePath, contents)), + Effect.catchCause(() => + Effect.sync(() => { + cacheDirty = true; + }), + ), + ); + } + }).pipe(persistLock.withPermit, Effect.withSpan("UsageService.persistCaches")); + + const pendingPersists = new Set>(); + /** Writes dirty caches in the background, after the summary that dirtied them answers. */ + const schedulePersist = Effect.forkDetach(persistCaches).pipe( + Effect.map((fiber) => { + pendingPersists.add(fiber); + fiber.addObserver(() => pendingPersists.delete(fiber)); + }), + ); + /** Waits for every write scheduled so far, as a restart would need. */ + const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe( + Effect.asVoid, + ); + // A write still running when the service shuts down finishes first, so the + // next start does not lose the last scan. + yield* Effect.addFinalizer(() => awaitPersisted); + + /** + * Parses one transcript, reusing the cached result when it is unchanged. + * + * A file that only grew re-parses from the cached position, so an actively + * written multi-hundred-megabyte rollout costs its appended bytes per scan + * rather than a full re-read. The reader verifies the position's guard bytes + * and silently restarts from byte 0 when they no longer match. + * + * A fresh parse comes back as `update` for the caller to cache, with the + * entry it was built from. Reads run concurrently, and the caller stores + * updates in walk order rather than completion order: saved records of + * deleted transcripts aggregate in cache order, where the first copy of a + * duplicate wins. + */ + const readFileRecords = ( + filePath: string, + size: number, + mtimeMs: number, + provider: UsageProviderKind, + format: TranscriptUsageFormat, + ): Effect.Effect<{ + readonly records: readonly UsageRecord[]; + readonly failed?: true; + readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; + }> => + Effect.gen(function* () { + const cached = fileCache.get(filePath); + // Provider is part of the identity: if both providers were ever pointed + // at one directory, a hit parsed by the other parser must not be reused. + if ( + cached && + cached.size === size && + cached.mtimeMs === mtimeMs && + cached.provider === provider + ) { + return { + records: + cached.tailRecords.length === 0 + ? cached.records + : [...cached.records, ...cached.tailRecords], + }; + } + + // Only a strictly grown file may resume. Same size with a new mtime, or + // a shrunken file, means rewritten content; re-parse it whole. + const resumeFrom = + cached !== undefined && cached.provider === provider && size > cached.size + ? cached.position + : undefined; + + const parsed = yield* Effect.promise(() => + readTranscriptRecords(filePath, format, resumeFrom), + ); + // A read failure is not an empty transcript: caching it under this + // (size, mtime) would silently drop the file's usage until it changes. + if (parsed === null) + return { + records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], + failed: true, + }; + + // Stored already de-duplicated within the file, which is 99% of all + // duplicates. The aggregator still runs the cross-file dedupe pass. One + // seen set spans the cached base, the new lines, and the tail so a + // resumed parse dedupes exactly like a full one. + const base = parsed.resumed && cached !== undefined ? cached.records : []; + const seen = new Set(); + const records = dedupeWithinFile([...base, ...parsed.records], seen); + const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + + return { + records: tailRecords.length === 0 ? records : [...records, ...tailRecords], + update: { + entry: { size, mtimeMs, provider, records, tailRecords, position: parsed.position }, + replaces: cached, + }, + }; + }); + + /** One provider directory's walk and parse, before rates are involved. */ + interface ScannedDir { + readonly provider: UsageProviderKind; + readonly dir: string; + readonly volumeId: string; + readonly hostId?: string; + readonly status?: UsageSource["status"]; + readonly message?: string; + readonly action?: UsageSource["action"]; + /** Parsed records per file, or `null` when the directory does not exist. */ + readonly files: + | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] + | null; + /** Answered from a cache while a refresh runs. */ + readonly refreshing?: true; + } + + const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* ( + source: TranscriptSource, + windowStartMs: number, + ) { + const { provider, format, dir, volumeId, fileName } = source; + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (!exists) return { provider, dir, volumeId, files: null } satisfies ScannedDir; + const { files, failedPaths } = yield* Effect.promise(() => + listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + ); + // A cold parse waits on disk reads, so a few files in flight read + // close to twice as fast. Results keep walk order. + const read = yield* Effect.forEach( + files, + (file) => + readFileRecords(file.path, file.size, file.mtimeMs, provider, format).pipe( + Effect.map((result) => ({ path: file.path, ...result })), + ), + { concurrency: TRANSCRIPT_READ_CONCURRENCY }, + ); + const parsedFiles = read.map(({ path, records, update }) => { + if (update === undefined) return { path, records }; + // A scan of another window may have cached its own read of this file + // meanwhile. Then keep whichever read saw the later file, so a slower + // scan never replaces newer usage with older. + const current = fileCache.get(path); + if ( + current === update.replaces || + current === undefined || + !isLaterRead(current, update.entry) + ) { + fileCache.set(path, update.entry); + cacheDirty = true; + } + return { path, records }; + }); + // Unread files keep their cached usage, but the total may be short. + const unread = failedPaths + read.filter((file) => file.failed).length; + return { + provider, + dir, + volumeId, + files: parsedFiles, + ...(unread > 0 + ? { + status: "partial", + message: `${unread} transcript path(s) could not be read; usage may be incomplete.`, + } + : {}), + } satisfies ScannedDir; + }); + + const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( + windowStartMs: number, + settings: ServerSettingsValue, + retentionCutoffMs: number, + awaitRefresh: boolean, + ) { + const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( + Effect.provideContext(readerContext), + ); + + const scans = Effect.forEach( + scanReaders, + ({ driver, reader }) => + usageInstances(driver, settings) + .pipe( + Effect.flatMap((instances) => + reader.scan({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }), + ), + ) + .pipe( + Effect.flatMap((sources) => + Effect.forEach(sources, ({ volumeId, ...source }) => + Effect.map( + volumeId === undefined + ? Effect.promise(() => readDirectoryVolumeId(source.dir)) + : Effect.succeed(volumeId), + (resolved): ScannedDir => ({ + ...source, + provider: reader.provider, + volumeId: resolved, + }), + ), + ), + ), + Effect.provideContext(readerContext), + ), + { concurrency: "unbounded" }, + ); + + // Independent sources scan together. Transcript directories go one at a + // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result + // keeps this order, since aggregation keeps the first copy of a duplicate. + const [transcripts, scanDirs] = yield* Effect.all( + [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans], + { concurrency: "unbounded" }, + ); + const scanned: readonly ScannedDir[] = [...transcripts, ...scanDirs.flat()]; + return scanned; + }); + + const scanSummary = Effect.fn("UsageService.scanSummary")(function* ( + input: UsageSummaryInput, + settings: ServerSettingsValue, + ) { + if (input.sinceDay > input.untilDay) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`, + }); + } + + let hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null; + if (input.resolution === "hour") { + const sinceTime = + input.sinceTime === undefined ? Option.none() : DateTime.make(input.sinceTime); + const untilTime = + input.untilTime === undefined ? Option.none() : DateTime.make(input.untilTime); + if (Option.isNone(sinceTime) || Option.isNone(untilTime)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Hourly usage requires valid sinceTime and untilTime instants", + }); + } + const sinceTimeMs = DateTime.toEpochMillis(sinceTime.value); + const untilTimeMs = DateTime.toEpochMillis(untilTime.value); + const durationMs = untilTimeMs - sinceTimeMs; + if (durationMs <= 0 || durationMs > MAX_HOURLY_WINDOW_MS) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Hourly usage window must be greater than zero and at most 24 hours", + }); + } + hourlyWindow = { sinceTimeMs, untilTimeMs }; + } + + const startedAtMs = yield* Clock.currentTimeMillis; + yield* ensureScanCacheLoaded; + + const hostId = NodeOS.hostname(); + const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); + if (Option.isNone(windowStart)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is not a valid date`, + }); + } + const windowStartMs = + (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + + const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; + + // Pricing only matters once records are aggregated, so the rate table + // loads while transcripts stream instead of gating them: a cold rates + // fetch on a slow network no longer delays the scan by its own timeout. + const [, scannedDirs] = yield* Effect.all( + [ + ensureRates(false), + collectDirs(windowStartMs, settings, retentionCutoffMs, input.awaitRefresh === true), + ], + { concurrency: 2 }, + ); + + const aggregator = new UsageAggregator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + resolution: input.resolution ?? "day", + ...hourlyWindow, + rates, + priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), + modelAliases: resolveModelAliases(settings.usageModelAliases), + }); + + const sources: UsageSource[] = []; + + // Cleanup may remove transcripts, but the usage we already saved still + // contributes to its source through the normal aggregation and dedupe + // path. Like the walk, skip files last written before the window: they + // cannot hold records inside it. + const retainedSinceMs = Math.max(windowStartMs, retentionCutoffMs); + const filesByDir = scannedDirs.map(({ provider, dir, files }) => { + const retainedFiles = [...(files ?? [])]; + const livePaths = new Set(retainedFiles.map((file) => file.path)); + for (const [filePath, entry] of fileCache) { + if ( + entry.provider !== provider || + entry.mtimeMs < retainedSinceMs || + livePaths.has(filePath) || + !isWithinDirectory(filePath, dir) + ) + continue; + retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] }); + } + return retainedFiles; + }); + const crossFileSessions = sharedSessions(filesByDir.flat()); + + for (const [ + index, + { provider, dir, volumeId, files, status, message, action, refreshing, hostId: sourceHostId }, + ] of scannedDirs.entries()) { + let scannedFiles = 0; + let skippedFiles = 0; + // Distinct per directory. Buckets carry per-cell session counts, but a + // session spans days and models, so clients total this figure instead. + const sessionIds = new Set(); + + for (const file of filesByDir[index] ?? []) { + if (file.records.length === 0) { + skippedFiles += 1; + continue; + } + scannedFiles += 1; + const eventOccurrences = new Map(); + for (const record of file.records) { + let usageRecord = record; + if ( + sharedSessionProviders.has(record.provider) && + crossFileSessions.has(record.sessionId) + ) { + // Match moved copies without collapsing repeated equal events + // within one file (timestamps can have only second precision). + // Only sessions seen in several files can have a copy to match. + const key = encodeUsageRecordKey([ + record.provider, + record.sessionId, + record.timestampMs, + record.model, + record.totals, + ]); + const occurrence = (eventOccurrences.get(key) ?? 0) + 1; + eventOccurrences.set(key, occurrence); + usageRecord = { ...record, dedupeKey: key + ":" + occurrence }; + } + // Only sessions contributing in-window count; the mtime slack can + // admit boundary files whose records fall outside the range. + if (aggregator.add(usageRecord, dir) && record.sessionId.length > 0) { + sessionIds.add(record.sessionId); + } + } + } + + sources.push({ + fingerprint: { hostId: sourceHostId ?? hostId, provider, resolvedHomePath: dir, volumeId }, + // Clients exclude missing sources, so saved records remain an available source. + status: files === null && scannedFiles === 0 ? "missing" : (status ?? "ok"), + scannedFiles, + skippedFiles, + malformedRecords: 0, + distinctSessions: sessionIds.size, + message: + message ?? (files === null ? "No transcript directory on this environment." : null), + ...(action ? { action } : {}), + ...(refreshing ? { refreshing } : {}), + }); + } + + const pruned = pruneScanCache(fileCache, retentionCutoffMs); + if (pruned > 0) cacheDirty = true; + + const aggregated = aggregator.finish(); + const readAt = yield* DateTime.now; + const finishedAtMs = yield* Clock.currentTimeMillis; + + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: DateTime.formatIso(readAt), + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: aggregated.buckets, + sources, + pricing: pricing(), + scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + } satisfies UsageSummary; + }); + + /** + * In-flight scans by window and settings, so concurrent identical requests (the usage + * page open on two clients at once) share one scan instead of racing over + * the same corpus twice. Readers read settings of their own, so a scan is + * shared only under the same settings snapshot. + */ + const inflightScans = new Map>(); + + const scanKey = (input: UsageSummaryInput, settings: ServerSettingsValue): string => + JSON.stringify([ + input.timeZone, + input.sinceDay, + input.untilDay, + input.resolution ?? "day", + input.sinceTime ?? null, + input.untilTime ?? null, + settings, + // A waiting read must never share a scan that answers with `refreshing`. + input.awaitRefresh === true, + ]); + + const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const settings = yield* readSettings; + const key = scanKey(input, settings); + const deferred = yield* Effect.uninterruptible( + Effect.gen(function* () { + const existing = inflightScans.get(key); + if (existing !== undefined) return existing; + + // Enrollment and detached-fiber creation must be atomic. Otherwise a + // canceled first caller can leave a Deferred with no scan to finish it. + const created = Deferred.makeUnsafe(); + inflightScans.set(key, created); + // Detached so one departing client cannot tear the scan out from under + // the fibers awaiting it; a finished scan warms the cache either way. + // The cache write is registered before the waiters resume, so they + // can await it, but its fiber starts after they have the summary. + yield* scanSummary(input, settings).pipe( + Effect.onExit((exit) => + Effect.sync(() => inflightScans.delete(key)).pipe( + Effect.andThen(Exit.isSuccess(exit) ? schedulePersist : Effect.void), + Effect.andThen(Deferred.done(created, exit)), + ), + ), + Effect.forkDetach, + ); + return created; + }), + ); + // Waiting stays interruptible. The detached scan continues for other + // callers and still warms the cache if this caller leaves. + return yield* Deferred.await(deferred); + }); + + // `awaitPersisted` is outside the service interface: tests use it to restart + // against what a previous instance wrote. + return { readSummary, refreshRates, awaitPersisted } as const; +}); diff --git a/packages/provider-acp-registry/package.json b/packages/provider-acp-registry/package.json index a9c474a9148f8..eb503ac3fe8b2 100644 --- a/packages/provider-acp-registry/package.json +++ b/packages/provider-acp-registry/package.json @@ -26,6 +26,10 @@ "./server/AcpRegistryRuntimeCoordinator": { "types": "./src/server/AcpRegistryRuntimeCoordinator.ts", "import": "./src/server/AcpRegistryRuntimeCoordinator.ts" + }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" } }, "scripts": { diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index 8ddf7ad9ce237..845ca69b812f8 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -1,3 +1,4 @@ +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; import { officialAcpRegistryIconUrlForAgentId, ProviderDriverKind, @@ -22,7 +23,7 @@ import * as Semaphore from "effect/Semaphore"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; -import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv } from "./adapter.ts"; +import type { AcpRegistryAdapterV2DriverEnv } from "./adapter.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; @@ -501,22 +502,30 @@ export const AcpRegistryDriver: ProviderDriver - new ProviderDriverError({ - driver: DRIVER_KIND, + DRIVER_KIND, + Effect.promise(() => import("./adapter.ts")).pipe( + Effect.flatMap(({ AcpRegistryAdapterV2Driver }) => + AcpRegistryAdapterV2Driver.create({ instanceId, - detail: "Failed to build ACP Registry orchestration adapter.", - cause, - }), + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build ACP Registry orchestration adapter.", + cause, + }), + ), + ), + ), ), ); const readinessInput = { diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index c45de9a9b149f..45c67f077177b 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -182,6 +182,10 @@ "./server/usageLimits": { "types": "./src/server/usageLimits.ts", "import": "./src/server/usageLimits.ts" + }, + "./server/lazyAdapter": { + "types": "./src/server/lazyAdapter.ts", + "import": "./src/server/lazyAdapter.ts" } }, "scripts": { diff --git a/packages/provider-core/src/server/lazyAdapter.ts b/packages/provider-core/src/server/lazyAdapter.ts new file mode 100644 index 0000000000000..d6fb5b9917087 --- /dev/null +++ b/packages/provider-core/src/server/lazyAdapter.ts @@ -0,0 +1,50 @@ +import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Scope from "effect/Scope"; +import * as Fiber from "effect/Fiber"; +import * as ProviderAdapter from "./ProviderAdapter.ts"; + +/** Keep construction in the instance scope while sharing it across concurrent first uses. */ +export const makeLazyAdapter = ( + instanceId: ProviderInstanceId, + driver: ProviderDriverKind, + make: Effect.Effect, +) => + Effect.gen(function* () { + const context = yield* Effect.context(); + const scope = yield* Scope.Scope; + const fiber = yield* Effect.cached( + make.pipe(Effect.provideContext(context), Effect.forkIn(scope)), + ); + const adapter = fiber.pipe(Effect.flatMap(Fiber.join)); + return ProviderAdapter.ProviderAdapterV2.of({ + instanceId, + driver, + getCapabilities: () => + adapter.pipe( + Effect.mapError( + (cause) => new ProviderAdapter.ProviderAdapterCapabilitiesError({ driver, cause }), + ), + Effect.flatMap((value) => value.getCapabilities()), + ), + planSelectionTransition: (input) => + adapter.pipe( + Effect.mapError( + (cause) => new ProviderAdapter.ProviderAdapterCapabilitiesError({ driver, cause }), + ), + Effect.flatMap((value) => value.planSelectionTransition(input)), + ), + openSession: (input) => + adapter.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver, + providerSessionId: input.providerSessionId, + cause, + }), + ), + Effect.flatMap((value) => value.openSession(input)), + ), + }); + }); diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index a4cdc141122d2..e2b73c894dc86 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -1,3 +1,7 @@ +import * as Exit from "effect/Exit"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as ProviderAdapter from "./ProviderAdapter.ts"; +import { makeLazyAdapter } from "./lazyAdapter.ts"; import { describe, it, assert } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, @@ -685,3 +689,73 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(layerAlwaysRunTest)), ); }); + +it.effect( + "constructs a lazy adapter once in its instance scope across concurrent request scopes", + () => + Effect.gen(function* () { + let constructed = 0; + let finalized = 0; + let owner: string | undefined; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const driver = ProviderDriverKind.make("codex"); + yield* Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeLazyAdapter( + ProviderInstanceId.make("lazy"), + driver, + Effect.gen(function* () { + constructed++; + owner = (yield* HostProcessEnvironment).OWNER; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + finalized++; + }), + ); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return ProviderAdapter.ProviderAdapterV2.of({ + instanceId: ProviderInstanceId.make("lazy"), + driver, + getCapabilities: () => + Effect.fail( + new ProviderAdapter.ProviderAdapterCapabilitiesError({ + driver, + cause: "probe refused", + }), + ), + planSelectionTransition: () => Effect.die("unused"), + openSession: () => Effect.die("unused"), + }); + }), + ); + assert.equal(constructed, 0); + const abandoned = yield* adapter.getCapabilities().pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(abandoned); + const requests = yield* Effect.all( + [ + adapter.getCapabilities().pipe(Effect.scoped, Effect.exit), + adapter.getCapabilities().pipe(Effect.scoped, Effect.exit), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.provideService(HostProcessEnvironment, { OWNER: "request" }), + Effect.forkScoped, + ); + yield* Deferred.await(started); + assert.equal(constructed, 1); + yield* Deferred.succeed(release, undefined); + const exits = yield* Fiber.join(requests); + assert.isTrue(exits.every(Exit.isFailure)); + assert.equal(owner, "instance"); + assert.equal(finalized, 0); + yield* adapter.getCapabilities().pipe(Effect.scoped, Effect.exit); + assert.equal(constructed, 1); + assert.equal(finalized, 0); + }).pipe(Effect.provideService(HostProcessEnvironment, { OWNER: "instance" })), + ); + assert.equal(finalized, 1); + }), +); diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json index b139d6ecc8dd8..af0808d97db9c 100644 --- a/packages/provider-cursor/package.json +++ b/packages/provider-cursor/package.json @@ -42,6 +42,14 @@ "./server/CursorSdk": { "types": "./src/server/CursorSdk.ts", "import": "./src/server/CursorSdk.ts" + }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/keychainToken": { + "types": "./src/server/keychainToken.ts", + "import": "./src/server/keychainToken.ts" } }, "scripts": { diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts index 5bd81aea13c95..a11c399e97749 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts @@ -94,16 +94,18 @@ const cursorSdkMock = vi.hoisted(() => { }); vi.mock("./sdk.ts", () => ({ - Agent: { - create: cursorSdkMock.create, - resume: cursorSdkMock.resume, - listRuns: cursorSdkMock.listRuns, - cancelRun: cursorSdkMock.cancelRun, - messages: { - list: vi.fn(async () => []), + loadCursorSdk: () => ({ + Agent: { + create: cursorSdkMock.create, + resume: cursorSdkMock.resume, + listRuns: cursorSdkMock.listRuns, + cancelRun: cursorSdkMock.cancelRun, + messages: { + list: vi.fn(async () => []), + }, }, - }, - createAgentPlatform: vi.fn(), + createAgentPlatform: vi.fn(), + }), })); const layerTest = CursorAgentSdk.layer.pipe( diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index e0162a4d062c2..677f22791166b 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -307,7 +307,7 @@ function makeCursorAgentSdkProtocolLogger(input: { export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(function* ( protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined, ) { - const { Agent, createAgentPlatform } = yield* CursorSdk.CursorSdk; + const sdk = yield* CursorSdk.CursorSdk; /** * The Cursor SDK decides once per process whether local sandboxing works, and @@ -329,7 +329,9 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu } yield* Effect.tryPromise(async () => { const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined; - const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd }); + const platform = await sdk.createAgentPlatform( + cwd === undefined ? {} : { workspaceRef: cwd }, + ); const release = await platform.prewarmLocalWorkspace({ ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), local: { @@ -345,6 +347,7 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu return CursorAgentSdkRunner.of({ open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) { + const { Agent } = sdk; if (input.options.local?.sandboxOptions?.enabled === false) { yield* primeCursorSandboxSupport(input.options); } diff --git a/packages/provider-cursor/src/server/CursorSdk.ts b/packages/provider-cursor/src/server/CursorSdk.ts index 0497bad3125f2..43a27e34db4c4 100644 --- a/packages/provider-cursor/src/server/CursorSdk.ts +++ b/packages/provider-cursor/src/server/CursorSdk.ts @@ -3,7 +3,8 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { Agent, createAgentPlatform } from "./sdk.ts"; +import { loadCursorSdk } from "./sdk.ts"; +import type { Agent, createAgentPlatform } from "@cursor/sdk"; /** * The Cursor Agent SDK runs in this process and spawns a shell for tool @@ -79,5 +80,16 @@ export const layer = Layer.effect( Effect.sync(() => { process.off("unhandledRejection", onUnhandledRejection); }), - ).pipe(Effect.as(CursorSdk.of({ Agent, createAgentPlatform }))), + ).pipe( + Effect.as( + CursorSdk.of({ + get Agent() { + return loadCursorSdk().Agent; + }, + get createAgentPlatform() { + return loadCursorSdk().createAgentPlatform; + }, + }), + ), + ), ); diff --git a/packages/provider-cursor/src/server/CursorSdkCatalog.ts b/packages/provider-cursor/src/server/CursorSdkCatalog.ts index 7f317cc0978fd..8d4df46d35d06 100644 --- a/packages/provider-cursor/src/server/CursorSdkCatalog.ts +++ b/packages/provider-cursor/src/server/CursorSdkCatalog.ts @@ -7,7 +7,7 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { AuthenticationError, Cursor, CursorSdkError } from "./sdk.ts"; +import { loadCursorSdk } from "./sdk.ts"; export interface CursorSdkCatalogSnapshot { readonly user: SDKUser; @@ -38,6 +38,7 @@ export class CursorSdkCatalog extends Context.Service< >()("@t3tools/provider-cursor/server/CursorSdkCatalog") {} function isAuthenticationFailure(cause: unknown): boolean { + const { AuthenticationError, CursorSdkError } = loadCursorSdk(); return ( cause instanceof AuthenticationError || (cause instanceof CursorSdkError && cause.status === 401) @@ -62,8 +63,8 @@ const sdkRequest = (tryRequest: () => Promise) => }); const liveProbes: CursorSdkCatalogProbes = { - readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })), - readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })), + readUser: (apiKey) => sdkRequest(() => loadCursorSdk().Cursor.me({ apiKey })), + readModels: (apiKey) => sdkRequest(() => loadCursorSdk().Cursor.models.list({ apiKey })), }; export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* ( diff --git a/packages/provider-cursor/src/server/auth.test.ts b/packages/provider-cursor/src/server/auth.test.ts index 3d31211ac5aef..0974f26bbbb90 100644 --- a/packages/provider-cursor/src/server/auth.test.ts +++ b/packages/provider-cursor/src/server/auth.test.ts @@ -13,7 +13,8 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { makeCursorAuth, type CursorAuthOptions } from "./auth.ts"; -import { InMemoryCredentialStore } from "./sdk.ts"; +import { loadCursorSdk } from "./sdk.ts"; +const { InMemoryCredentialStore } = loadCursorSdk(); const instanceId = ProviderInstanceId.make("cursor-personal"); const owner = "owner-client"; diff --git a/packages/provider-cursor/src/server/auth.ts b/packages/provider-cursor/src/server/auth.ts index ca1c64d9b2c0d..2730a3688dc93 100644 --- a/packages/provider-cursor/src/server/auth.ts +++ b/packages/provider-cursor/src/server/auth.ts @@ -16,7 +16,6 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { Cursor, InMemoryCredentialStore } from "./sdk.ts"; import type { ProviderAuthController } from "@t3tools/provider-core/server/auth"; const AUTH_TIMEOUT_MS = 300_000; @@ -160,6 +159,9 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu Effect.gen(function* () { yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials)); // The SDK first writes into memory. Cancellation must not save a late login result. + const { Cursor, InMemoryCredentialStore } = yield* Effect.promise( + () => import("./sdk.ts"), + ).pipe(Effect.map(({ loadCursorSdk }) => loadCursorSdk())); const pendingStore = new InMemoryCredentialStore(); const urls = yield* Queue.unbounded(); yield* Queue.take(urls).pipe( diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index 8215f6563d488..2c2fd49183d78 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -19,7 +19,8 @@ import * as CursorKeychain from "./CursorKeychain.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -import { Cursor } from "./sdk.ts"; +import { loadCursorSdk } from "./sdk.ts"; +const { Cursor } = loadCursorSdk(); const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( Layer.provideMerge(NodeServices.layer), diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 2c748bb70d5e3..42ab16e0ce71a 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -1,3 +1,4 @@ +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; /** * CursorDriver — `ProviderDriver` for the Cursor Agent SDK runtime. * @@ -18,7 +19,7 @@ import { readCursorUsageLimits } from "./usageLimits.ts"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { makeCursorTextGeneration } from "./textGeneration.ts"; -import { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./adapter.ts"; +import type { CursorAdapterV2DriverEnv } from "./adapter.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus } from "./status.ts"; import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; @@ -163,48 +164,56 @@ export const CursorDriver: ProviderDriver< }, }); - const orchestrationAdapter = yield* CursorAdapterV2Driver.create({ + const orchestrationAdapter = yield* makeLazyAdapter( instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }).pipe( - Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { - ...sdkRunner, - open: (input) => - auth.requireApiKey.pipe( - Effect.flatMap((apiKey) => - Effect.acquireRelease( - sdkRunner - .open({ ...input, options: { ...input.options, apiKey } }) - .pipe( - Effect.flatMap((session) => - Effect.cached(session.close).pipe( - Effect.map((close) => ({ ...session, close })), - ), + DRIVER_KIND, + Effect.promise(() => import("./adapter.ts")).pipe( + Effect.flatMap(({ CursorAdapterV2Driver }) => + CursorAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { + ...sdkRunner, + open: (input) => + auth.requireApiKey.pipe( + Effect.flatMap((apiKey) => + Effect.acquireRelease( + sdkRunner + .open({ ...input, options: { ...input.options, apiKey } }) + .pipe( + Effect.flatMap((session) => + Effect.cached(session.close).pipe( + Effect.map((close) => ({ ...session, close })), + ), + ), + ), + (session) => session.close.pipe(Effect.ignore), ), ), - (session) => session.close.pipe(Effect.ignore), - ), - ), - auth.withAccess, - Effect.mapError((cause) => - isSdkRunnerError(cause) - ? cause - : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }), + auth.withAccess, + Effect.mapError((cause) => + isSdkRunnerError(cause) + ? cause + : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }), + ), + ), + }), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Cursor orchestration adapter.", + cause, + }), ), ), - }), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Cursor orchestration adapter.", - cause, - }), + ), ), ); const textGeneration = yield* makeCursorTextGeneration( diff --git a/packages/provider-cursor/src/server/sdk.ts b/packages/provider-cursor/src/server/sdk.ts index 602a0d0d2fd44..279fcfa33fa08 100644 --- a/packages/provider-cursor/src/server/sdk.ts +++ b/packages/provider-cursor/src/server/sdk.ts @@ -4,11 +4,6 @@ import * as NodeModule from "node:module"; // Cursor's Webpack chunks and local helpers must stay beside the SDK entry. // createRequire also loads that disk-backed package from a Node SEA executable. const requireCursorSdk = NodeModule.createRequire(import.meta.url); -export const { - Agent, - AuthenticationError, - createAgentPlatform, - Cursor, - CursorSdkError, - InMemoryCredentialStore, -} = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk"); +let sdk: typeof import("@cursor/sdk") | undefined; +export const loadCursorSdk = () => + (sdk ??= requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk")); diff --git a/packages/provider-cursor/src/server/textGeneration.test.ts b/packages/provider-cursor/src/server/textGeneration.test.ts index 89d6ec895d135..49f9104832d48 100644 --- a/packages/provider-cursor/src/server/textGeneration.test.ts +++ b/packages/provider-cursor/src/server/textGeneration.test.ts @@ -28,8 +28,10 @@ const cursorSdkMock = vi.hoisted(() => ({ })); vi.mock("./sdk.ts", () => ({ - Agent: { create: cursorSdkMock.create }, - createAgentPlatform: vi.fn(), + loadCursorSdk: () => ({ + Agent: { create: cursorSdkMock.create }, + createAgentPlatform: vi.fn(), + }), })); let hasCustomPolicy = false; diff --git a/packages/provider-cursor/src/server/textGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts index ff1325d648d3e..afcaba03393da 100644 --- a/packages/provider-cursor/src/server/textGeneration.ts +++ b/packages/provider-cursor/src/server/textGeneration.ts @@ -58,7 +58,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu withAccess?: CursorAuth["withAccess"], ) { const fs = yield* FileSystem.FileSystem; - const { Agent } = yield* CursorSdk.CursorSdk; + const sdk = yield* CursorSdk.CursorSdk; const resolvedEnvironment = environment ?? process.env; const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) => @@ -112,7 +112,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu } satisfies AgentOptions; const createCursorAgent = (sandboxEnabled: boolean) => Effect.tryPromise((signal) => - Agent.create( + sdk.Agent.create( sandboxEnabled ? agentOptions : { diff --git a/packages/provider-grok/package.json b/packages/provider-grok/package.json index 95ba20214e81a..1c34f92ae6584 100644 --- a/packages/provider-grok/package.json +++ b/packages/provider-grok/package.json @@ -22,6 +22,10 @@ "./testing": { "types": "./src/testing.ts", "import": "./src/testing.ts" + }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" } }, "scripts": { diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index fe5b8705471aa..601e35e772403 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -1,3 +1,4 @@ +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; import { ProviderDriverKind } from "@t3tools/contracts"; import { GrokSettings } from "../settings.ts"; import * as Crypto from "effect/Crypto"; @@ -12,7 +13,7 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeGrokTextGeneration } from "./textGeneration.ts"; -import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./adapter.ts"; +import type { GrokAdapterV2DriverEnv } from "./adapter.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { buildInitialGrokProviderSnapshot, @@ -125,22 +126,30 @@ export const GrokDriver: ProviderDriver Effect.provideService(Path.Path, path), ), ); - const orchestrationAdapter = yield* GrokAdapterV2Driver.create({ + const orchestrationAdapter = yield* makeLazyAdapter( instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, + DRIVER_KIND, + Effect.promise(() => import("./adapter.ts")).pipe( + Effect.flatMap(({ GrokAdapterV2Driver }) => + GrokAdapterV2Driver.create({ instanceId, - detail: "Failed to build Grok orchestration adapter.", - cause, - }), + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Grok orchestration adapter.", + cause, + }), + ), + ), + ), ), ); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); diff --git a/packages/provider-muse/package.json b/packages/provider-muse/package.json index a59c6cd20c8df..f36272e301465 100644 --- a/packages/provider-muse/package.json +++ b/packages/provider-muse/package.json @@ -18,6 +18,10 @@ "./testing": { "types": "./src/testing.ts", "import": "./src/testing.ts" + }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" } }, "scripts": { diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 9a70f74eed835..7f0f86830d2bd 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -1,3 +1,4 @@ +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; import { ProviderDriverKind } from "@t3tools/contracts"; import { MuseSettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -14,7 +15,6 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { makeMuseTextGeneration } from "./textGeneration.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; -import { makeMuseAdapterV2 } from "./adapter.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { checkMuseProviderStatus, makePendingMuseProvider } from "./status.ts"; @@ -157,14 +157,22 @@ export const MuseDriver: ProviderDriver = { ), ); const modelCatalog = snapshot.getSnapshot.pipe(Effect.map((current) => current.models)); - const orchestrationAdapter = yield* makeMuseAdapterV2({ + const orchestrationAdapter = yield* makeLazyAdapter( instanceId, - settings: effectiveConfig, - environment: processEnvironment, - modelCatalog, - ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), - continuationRequests, - }); + DRIVER_KIND, + Effect.promise(() => import("./adapter.ts")).pipe( + Effect.flatMap(({ makeMuseAdapterV2 }) => + makeMuseAdapterV2({ + instanceId, + settings: effectiveConfig, + environment: processEnvironment, + modelCatalog, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + continuationRequests, + }), + ), + ), + ); const textGeneration = yield* makeMuseTextGeneration(effectiveConfig, { environment: processEnvironment, modelCatalog, diff --git a/packages/provider-opencode/package.json b/packages/provider-opencode/package.json index 9aa836a69a6d1..f6e3ae1f24bcf 100644 --- a/packages/provider-opencode/package.json +++ b/packages/provider-opencode/package.json @@ -42,6 +42,10 @@ "./server/v2/textGeneration": { "types": "./src/server/v2/textGeneration.ts", "import": "./src/server/v2/textGeneration.ts" + }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" } }, "scripts": { diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index 5e87afe5ed598..1a8adfd7e34da 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -1,17 +1,16 @@ import * as NodeURL from "node:url"; import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; -import { - createOpencodeClient, - type Agent, - type Command, - type FilePartInput, - type Model, - type OpencodeClient, - type PermissionRuleset, - type ProviderListResponse, - type QuestionAnswer, - type QuestionRequest, +import type { + Agent, + Command, + FilePartInput, + Model, + OpencodeClient, + PermissionRuleset, + ProviderListResponse, + QuestionAnswer, + QuestionRequest, } from "@opencode-ai/sdk/v2"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -275,7 +274,7 @@ export class OpenCodeRuntime extends Context.Service< readonly baseUrl: string; readonly directory: string; readonly serverPassword?: string; - }) => OpencodeClient; + }) => Effect.Effect; readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect; @@ -658,18 +657,22 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); const createOpenCodeSdkClient: OpenCodeRuntime["Service"]["createOpenCodeSdkClient"] = (input) => - createOpencodeClient({ - baseUrl: input.baseUrl, - directory: input.directory, - ...(input.serverPassword - ? { - headers: { - Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, - }, - } - : {}), - throwOnError: true, - }); + Effect.promise(() => import("@opencode-ai/sdk/v2")).pipe( + Effect.map(({ createOpencodeClient }) => + createOpencodeClient({ + baseUrl: input.baseUrl, + directory: input.directory, + ...(input.serverPassword + ? { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, + }, + } + : {}), + throwOnError: true, + }), + ), + ); const startOpenCodeServerProcess: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = ( input, @@ -866,13 +869,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const url = readyOption.value; const version = yield* ( input.verify?.(url) ?? - verifyOpenCodeServerVersion( - createOpenCodeSdkClient({ - baseUrl: url, - directory: input.directory, - ...(serverPassword !== undefined ? { serverPassword } : {}), - }), - ) + createOpenCodeSdkClient({ + baseUrl: url, + directory: input.directory, + ...(serverPassword !== undefined ? { serverPassword } : {}), + }).pipe(Effect.flatMap(verifyOpenCodeServerVersion)) ); return { @@ -896,21 +897,21 @@ const makeOpenCodeRuntime = Effect.gen(function* () { external: true, ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), }); - return verifyOpenCodeServerVersion( - createOpenCodeSdkClient({ - baseUrl: serverUrl, - directory: input.directory, - ...(serverPassword !== undefined ? { serverPassword } : {}), - }), - ).pipe( - Effect.map((version) => ({ - url: serverUrl, - ...(serverPassword !== undefined ? { serverPassword } : {}), - version, - exitCode: null, - external: true, - })), - ); + return createOpenCodeSdkClient({ + baseUrl: serverUrl, + directory: input.directory, + ...(serverPassword !== undefined ? { serverPassword } : {}), + }) + .pipe(Effect.flatMap(verifyOpenCodeServerVersion)) + .pipe( + Effect.map((version) => ({ + url: serverUrl, + ...(serverPassword !== undefined ? { serverPassword } : {}), + version, + exitCode: null, + external: true, + })), + ); } return startOpenCodeServerProcess({ diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index c2ec42bd16b5f..9f995cc8abd4f 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -164,7 +164,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }).pipe( Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, { connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), - createOpenCodeSdkClient: () => client, + createOpenCodeSdkClient: () => Effect.succeed(client), } as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]), Effect.provideService(ProviderHost.ProviderHost, { paths: { cwd: "/workspace" }, @@ -1537,7 +1537,7 @@ describe("OpenCodeAdapterV2", () => { Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, { connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), - createOpenCodeSdkClient: () => client, + createOpenCodeSdkClient: () => Effect.succeed(client), } as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]), Effect.provideService(ProviderHost.ProviderHost, { paths: { cwd: "/workspace" }, @@ -1834,7 +1834,7 @@ describe("OpenCodeAdapterV2", () => { Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, { connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), - createOpenCodeSdkClient: () => client, + createOpenCodeSdkClient: () => Effect.succeed(client), } as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]), Effect.provideService(ProviderHost.ProviderHost, { paths: { cwd: "/workspace" }, @@ -2517,7 +2517,7 @@ describe("OpenCodeAdapterV2", () => { external: true, }), runOpenCodeCommand: unused("runOpenCodeCommand"), - createOpenCodeSdkClient: () => fakeClient, + createOpenCodeSdkClient: () => Effect.succeed(fakeClient), loadOpenCodeInventory: unused("loadOpenCodeInventory"), loadInventoryFromCli: unused("loadInventoryFromCli"), loadOpenCodeSkills: unused("loadOpenCodeSkills"), diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 327d4d8cef4f3..6876c1e56dd7b 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -961,7 +961,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function serverUrl: options.settings.serverUrl, environment: options.environment, }); - const client = runtime.createOpenCodeSdkClient({ + const client = yield* runtime.createOpenCodeSdkClient({ baseUrl: connection.url, directory: cwd, ...(connection.external && options.settings.serverPassword diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 64b18e0db88ba..2e0e61c42bf6c 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -9,7 +9,6 @@ import { ProviderInstanceId } from "@t3tools/contracts"; import type { OpenCodeSettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; @@ -27,6 +26,7 @@ import { replayOpenCodeServer, } from "./probeResponses.fixture.ts"; import { OpenCodeDriver, openCodeUpdateFor } from "./driver.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; const serverStarts: Array = []; @@ -75,7 +75,14 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { it.effect("never starts a 1.x server for an OpenCode 2 instance", () => Effect.gen(function* () { serverStarts.length = 0; - const instance = yield* create({}, noHttp); + const host = yield* ProviderHost.ProviderHost; + const instance = yield* create({}, noHttp).pipe( + Effect.provideService(ProviderHost.ProviderHost, { + ...host, + awaitStartupProbe: Effect.never, + }), + ); + // Keep the startup status probe gated, as it is before the server is ready. // Text generation goes to the instance's 2.x server, which this test refuses to start. yield* Effect.flip( instance.textGeneration.generateThreadTitle({ @@ -122,12 +129,9 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { const instance = yield* create({ serverUrl: "http://127.0.0.1:9" }, hang); // No probe has succeeded yet, and the server never answers: the 1.x default applies. - const capabilities = yield* instance.orchestrationAdapter - .getCapabilities() - .pipe(Effect.forkChild); - yield* Effect.yieldNow; - assert.isDefined(capabilities.pollUnsafe()); - yield* Fiber.join(capabilities); + // First use may load the adapter, but completes without advancing the probe's clock. + const capabilities = yield* instance.orchestrationAdapter.getCapabilities(); + assert.isDefined(capabilities); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); }); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index f3340a62e9e0b..9ec46cd2a3274 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -25,12 +25,12 @@ import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as OpenCode2TextGeneration from "./v2/textGeneration.ts"; import { makeOpenCodeTextGeneration } from "./textGeneration.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; -import * as OpenCodeAdapterV2 from "./adapter.ts"; -import * as OpenCode2AdapterV2 from "./v2/adapter.ts"; +import type * as OpenCodeAdapterV2 from "./adapter.ts"; +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; + import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; @@ -263,22 +263,30 @@ export const OpenCodeDriver: ProviderDriver< Effect.andThen(cachedMaintenance({ fresh: true })), ) : cachedMaintenance(); - const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({ + const openCodeV1Adapter = yield* makeLazyAdapter( instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, + DRIVER_KIND, + Effect.promise(() => import("./adapter.ts")).pipe( + Effect.flatMap(({ OpenCodeAdapterV2Driver }) => + OpenCodeAdapterV2Driver.create({ instanceId, - detail: "Failed to build OpenCode orchestration adapter.", - cause, - }), + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build OpenCode orchestration adapter.", + cause, + }), + ), + ), + ), ), ); // One OpenCode 2 server per instance, spawned on first use or reached at `serverUrl`. @@ -300,8 +308,13 @@ export const OpenCodeDriver: ProviderDriver< const orchestrationAdapter = selectOpenCodeRuntimeAdapter({ probe: runtimeProbe, v1: openCodeV1Adapter, - v2: yield* OpenCode2AdapterV2.make(instanceId).pipe( - Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), + v2: yield* makeLazyAdapter( + instanceId, + DRIVER_KIND, + Effect.promise(() => import("./v2/adapter.ts")).pipe( + Effect.flatMap((adapter) => adapter.make(instanceId)), + Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), + ), ), }); const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( @@ -368,9 +381,33 @@ export const OpenCodeDriver: ProviderDriver< yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), ), - yield* OpenCode2TextGeneration.make().pipe( - Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), - ), + yield* Effect.gen(function* () { + const context = + yield* Effect.context< + Exclude< + Effect.Services>, + OpenCode2Server.OpenCode2Server + > + >(); + const generation = yield* Effect.cached( + Effect.promise(() => import("./v2/textGeneration.ts")).pipe( + Effect.flatMap((module) => module.make()), + Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), + Effect.provideContext(context), + ), + ); + const deferred: ProviderTextGeneration = { + generateCommitMessage: (input) => + generation.pipe(Effect.flatMap((value) => value.generateCommitMessage(input))), + generatePrContent: (input) => + generation.pipe(Effect.flatMap((value) => value.generatePrContent(input))), + generateBranchName: (input) => + generation.pipe(Effect.flatMap((value) => value.generateBranchName(input))), + generateThreadTitle: (input) => + generation.pipe(Effect.flatMap((value) => value.generateThreadTitle(input))), + }; + return deferred; + }), ); const checkProvider = Effect.all( @@ -432,7 +469,7 @@ export const OpenCodeDriver: ProviderDriver< : {}), environment: processEnv, }); - const client = openCodeRuntime.createOpenCodeSdkClient({ + const client = yield* openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: cwd, ...(effectiveConfig.serverPassword @@ -443,15 +480,15 @@ export const OpenCodeDriver: ProviderDriver< }), ) : serverOwner.withServer((server) => - loadWorkspaceInventory( - openCodeRuntime.createOpenCodeSdkClient({ + openCodeRuntime + .createOpenCodeSdkClient({ baseUrl: server.url, directory: cwd, ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), - }), - ), + }) + .pipe(Effect.flatMap(loadWorkspaceInventory)), ); const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index c84dbdf3f8941..fb74d65fb0b14 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -264,9 +264,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), createOpenCodeSdkClient: (input) => { runtimeMock.state.sdkClientInputs.push(input); - return {} as unknown as ReturnType< - OpenCodeRuntime.OpenCodeRuntime["Service"]["createOpenCodeSdkClient"] - >; + return Effect.succeed( + {} as unknown as Effect.Success< + ReturnType + >, + ); }, loadOpenCodeInventory: () => runtimeMock.state.inventoryError diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index f89298af01ae6..4a3231a708c16 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -675,14 +675,15 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu readonly version: string; }) => openCodeRuntime - .loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), - }), - ) - .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); + .createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), + }) + .pipe( + Effect.flatMap((client) => openCodeRuntime.loadOpenCodeInventory(client)), + Effect.map((inventory) => ({ inventory, version: server.version })), + ); const inventoryEffect = isExternalServer ? openCodeRuntime .connectToOpenCodeServer({ diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index 48f85e60b6cbf..d18190f71f26b 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -93,7 +93,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { }), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => - ({ + Effect.succeed({ session: { create: async () => { runtimeMock.state.sessionCreateCalls += 1; @@ -128,9 +128,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { ); }, }, - }) as unknown as ReturnType< - OpenCodeRuntime.OpenCodeRuntime["Service"]["createOpenCodeSdkClient"] - >, + } as unknown as Effect.Success< + ReturnType + >), loadOpenCodeInventory: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index 8a48eaec7b415..0f8a734651014 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -179,7 +179,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" "url" | "serverPassword" | "version" >, ) { - const client = openCodeRuntime.createOpenCodeSdkClient({ + const client = yield* openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd, ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts index fcfb87e412605..9aaa141b28fb8 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts @@ -1,30 +1,21 @@ -import { OpenCode, type OpenCodeClient } from "@opencode/client/effect"; -import { OpenCodeEvent } from "@opencode/protocol/groups/event"; +import type { OpenCodeClient } from "@opencode/client/effect"; +import type { OpenCodeEvent } from "@opencode/protocol/groups/event"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Redacted from "effect/Redacted"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as Sse from "effect/encoding/Sse"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientError from "effect/http/HttpClientError"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; -import * as HttpClientResponse from "effect/http/HttpClientResponse"; - -/** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */ -const OPENCODE_USERNAME = "opencode"; - -/** - * A session's execution ended, but this build could not decode the event (a - * newer server added a field or a value). Only the type and session survive, - * so the turn still ends. - */ +import type * as Sse from "effect/encoding/Sse"; +import type * as HttpClientError from "effect/http/HttpClientError"; +import * as Option from "effect/Option"; +import type * as Redacted from "effect/Redacted"; export interface OpenCode2UnreadableTerminal { readonly type: "unreadable.execution.ended"; - readonly executionType: (typeof EXECUTION_ENDS)[number]; + readonly executionType: + | "session.execution.succeeded" + | "session.execution.failed" + | "session.execution.interrupted"; readonly sessionID: string; } @@ -37,16 +28,6 @@ export interface OpenCode2UnreadableStart { readonly sessionID: string; } -/** The events that end an execution; any other undecodable event is skipped. */ -const EXECUTION_ENDS = [ - "session.execution.succeeded", - "session.execution.failed", - "session.execution.interrupted", -] as const; -const isExecutionEnd = (type: string): type is (typeof EXECUTION_ENDS)[number] => - (EXECUTION_ENDS as ReadonlyArray).includes(type); - -/** The server sent nothing, not even a heartbeat, for `SILENT_STREAM_TIMEOUT`. */ export class OpenCode2SilentStreamError extends Schema.TaggedError()( "OpenCode2SilentStreamError", {}, @@ -61,15 +42,6 @@ export type OpenCode2StreamEvent = | OpenCode2UnreadableTerminal | OpenCode2UnreadableStart; -/** - * An OpenCode 2 client plus a forward-compatible `/api/event` stream. The - * client's own `event.subscribe` fails the whole stream on the first event a - * newer server adds, so `events` decodes each frame on its own and skips the - * ones this build does not know, except an execution's end or start, which it - * keeps as an {@link OpenCode2UnreadableTerminal} or {@link OpenCode2UnreadableStart}. - * `events` succeeds once the server accepted the subscription: the stream is - * volatile, so callers subscribe before they start work whose events they need. - */ export interface OpenCode2Api { readonly client: OpenCodeClient; readonly events: Effect.Effect< @@ -81,7 +53,6 @@ export interface OpenCode2Api { >; } -/** Builds clients for OpenCode 2 servers, one per base URL and password. */ export class OpenCode2Client extends Context.Service< OpenCode2Client, { @@ -91,110 +62,20 @@ export class OpenCode2Client extends Context.Service< }) => Effect.Effect; } >()("@t3tools/provider-opencode/server/v2/OpenCode2Client") {} - -/** - * OpenCode writes a `: heartbeat` comment every 10 to 15 seconds, so this long - * without a single byte means the server is stuck, not idle. - */ -const SILENT_STREAM_TIMEOUT = "45 seconds"; - -const decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(OpenCodeEvent)); -// Just enough of an event to route it, for frames the full schema rejects. -const decodeEnvelope = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - type: Schema.String, - data: Schema.optional(Schema.Struct({ sessionID: Schema.optional(Schema.String) })), - }), - ), -); - -const undecodable = (data: string) => - Effect.gen(function* () { - const envelope = decodeEnvelope(data); - const type = envelope._tag === "Some" ? envelope.value.type.slice(0, 80) : ""; - const sessionID = envelope._tag === "Some" ? envelope.value.data?.sessionID : undefined; - if (isExecutionEnd(type) && sessionID !== undefined) { - yield* Effect.logWarning( - "Ended an OpenCode execution from an event this build cannot decode.", - { - type, - }, - ); - return Result.succeed({ - type: "unreadable.execution.ended", - executionType: type, - sessionID, - }); - } - if (type === "session.execution.started" && sessionID !== undefined) { - yield* Effect.logDebug("Read an OpenCode execution start this build cannot decode."); - return Result.succeed({ - type: "unreadable.execution.started", - sessionID, - }); - } - yield* Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { type }); - return Result.failVoid; - }); - -/** Subscribes to `/api/event`, then streams every frame this build can route. */ -const readEvents = HttpClient.get("/api/event", { - headers: { accept: "text/event-stream" }, -}).pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.map((response) => - response.stream.pipe( - Stream.timeoutOrElse({ - duration: SILENT_STREAM_TIMEOUT, - orElse: () => Stream.fail(new OpenCode2SilentStreamError()), - }), - Stream.decodeText, - Stream.pipeThroughChannel(Sse.decode()), - Stream.filterMapEffect((frame) => { - const decoded = decodeEvent(frame.data); - return Result.isSuccess(decoded) - ? Effect.succeed(Result.succeed(decoded.success)) - : undecodable(frame.data); - }), - ), - ), -); - -/** - * OpenCode decodes Basic credentials as UTF-8. `HttpClientRequest.basicAuth` - * encodes them as Latin-1 (`btoa`), which gets non-ASCII passwords rejected. - */ -const basicAuthorization = (password: string | Redacted.Redacted) => { - const plain = Redacted.isRedacted(password) ? Redacted.value(password) : password; - return `Basic ${Buffer.from(`${OPENCODE_USERNAME}:${plain}`, "utf8").toString("base64")}`; -}; - -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; + const context = yield* Effect.context< + Effect.Services | Scope.Scope + >(); + const live = yield* Effect.cached( + Effect.promise(() => import("./OpenCode2ClientLive.ts")).pipe( + Effect.flatMap((module) => module.make), + Effect.provideContext(context), + ), + ); return OpenCode2Client.of({ - connect: ({ baseUrl, password }) => { - const authenticated = HttpClient.mapRequest( - httpClient, - HttpClientRequest.setHeader("Authorization", basicAuthorization(password)), - ); - return OpenCode.make({ baseUrl }).pipe( - Effect.provideService(HttpClient.HttpClient, authenticated), - Effect.map((client) => ({ - client, - events: readEvents.pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.mapRequest(authenticated, HttpClientRequest.prependUrl(baseUrl)), - ), - ), - })), - ); - }, + connect: (input) => live.pipe(Effect.flatMap((value) => value.connect(input))), }); }); - export const layer = Layer.effect(OpenCode2Client, make); /** diff --git a/packages/provider-opencode/src/server/v2/OpenCode2ClientLive.ts b/packages/provider-opencode/src/server/v2/OpenCode2ClientLive.ts new file mode 100644 index 0000000000000..3d309d05d8ade --- /dev/null +++ b/packages/provider-opencode/src/server/v2/OpenCode2ClientLive.ts @@ -0,0 +1,148 @@ +import { + OpenCode2Client, + OpenCode2SilentStreamError, + type OpenCode2StreamEvent, +} from "./OpenCode2Client.ts"; +import { OpenCode } from "@opencode/client/effect"; +import { OpenCodeEvent } from "@opencode/protocol/groups/event"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as Sse from "effect/encoding/Sse"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +/** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */ +const OPENCODE_USERNAME = "opencode"; + +/** + * A session's execution ended, but this build could not decode the event (a + * newer server added a field or a value). Only the type and session survive, + * so the turn still ends. + */ +/** The events that end an execution; any other undecodable event is skipped. */ +const EXECUTION_ENDS = [ + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", +] as const; +const isExecutionEnd = (type: string): type is (typeof EXECUTION_ENDS)[number] => + (EXECUTION_ENDS as ReadonlyArray).includes(type); + +/** The server sent nothing, not even a heartbeat, for `SILENT_STREAM_TIMEOUT`. */ +/** + * An OpenCode 2 client plus a forward-compatible `/api/event` stream. The + * client's own `event.subscribe` fails the whole stream on the first event a + * newer server adds, so `events` decodes each frame on its own and skips the + * ones this build does not know, except an execution's end or start, which it + * keeps as an {@link OpenCode2UnreadableTerminal} or {@link OpenCode2UnreadableStart}. + * `events` succeeds once the server accepted the subscription: the stream is + * volatile, so callers subscribe before they start work whose events they need. + */ +/** Builds clients for OpenCode 2 servers, one per base URL and password. */ + +/** + * OpenCode writes a `: heartbeat` comment every 10 to 15 seconds, so this long + * without a single byte means the server is stuck, not idle. + */ +const SILENT_STREAM_TIMEOUT = "45 seconds"; + +const decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(OpenCodeEvent)); +// Just enough of an event to route it, for frames the full schema rejects. +const decodeEnvelope = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + type: Schema.String, + data: Schema.optional(Schema.Struct({ sessionID: Schema.optional(Schema.String) })), + }), + ), +); + +const undecodable = (data: string) => + Effect.gen(function* () { + const envelope = decodeEnvelope(data); + const type = envelope._tag === "Some" ? envelope.value.type.slice(0, 80) : ""; + const sessionID = envelope._tag === "Some" ? envelope.value.data?.sessionID : undefined; + if (isExecutionEnd(type) && sessionID !== undefined) { + yield* Effect.logWarning( + "Ended an OpenCode execution from an event this build cannot decode.", + { + type, + }, + ); + return Result.succeed({ + type: "unreadable.execution.ended", + executionType: type, + sessionID, + }); + } + if (type === "session.execution.started" && sessionID !== undefined) { + yield* Effect.logDebug("Read an OpenCode execution start this build cannot decode."); + return Result.succeed({ + type: "unreadable.execution.started", + sessionID, + }); + } + yield* Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { type }); + return Result.failVoid; + }); + +/** Subscribes to `/api/event`, then streams every frame this build can route. */ +const readEvents = HttpClient.get("/api/event", { + headers: { accept: "text/event-stream" }, +}).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.map((response) => + response.stream.pipe( + Stream.timeoutOrElse({ + duration: SILENT_STREAM_TIMEOUT, + orElse: () => Stream.fail(new OpenCode2SilentStreamError()), + }), + Stream.decodeText, + Stream.pipeThroughChannel(Sse.decode()), + Stream.filterMapEffect((frame) => { + const decoded = decodeEvent(frame.data); + return Result.isSuccess(decoded) + ? Effect.succeed(Result.succeed(decoded.success)) + : undecodable(frame.data); + }), + ), + ), +); + +/** + * OpenCode decodes Basic credentials as UTF-8. `HttpClientRequest.basicAuth` + * encodes them as Latin-1 (`btoa`), which gets non-ASCII passwords rejected. + */ +const basicAuthorization = (password: string | Redacted.Redacted) => { + const plain = Redacted.isRedacted(password) ? Redacted.value(password) : password; + return `Basic ${Buffer.from(`${OPENCODE_USERNAME}:${plain}`, "utf8").toString("base64")}`; +}; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + return OpenCode2Client.of({ + connect: ({ baseUrl, password }) => { + const authenticated = HttpClient.mapRequest( + httpClient, + HttpClientRequest.setHeader("Authorization", basicAuthorization(password)), + ); + return OpenCode.make({ baseUrl }).pipe( + Effect.provideService(HttpClient.HttpClient, authenticated), + Effect.map((client) => ({ + client, + events: readEvents.pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.mapRequest(authenticated, HttpClientRequest.prependUrl(baseUrl)), + ), + ), + })), + ); + }, + }); +}); diff --git a/packages/provider-pi/package.json b/packages/provider-pi/package.json index 7387a33303f68..bfbf98e49509f 100644 --- a/packages/provider-pi/package.json +++ b/packages/provider-pi/package.json @@ -14,6 +14,10 @@ "./server": { "types": "./src/server.ts", "import": "./src/server.ts" + }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" } }, "scripts": { diff --git a/packages/provider-pi/src/server/driver.ts b/packages/provider-pi/src/server/driver.ts index c3790b40b1255..45b2f023077a0 100644 --- a/packages/provider-pi/src/server/driver.ts +++ b/packages/provider-pi/src/server/driver.ts @@ -1,3 +1,4 @@ +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; /** * PiDriver — v1 `ProviderDriver` for the Pi coding agent, composing the * orchestrator-v2 adapter (`PiAdapterV2`), the snapshot/probe layer @@ -18,7 +19,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { makePiTextGeneration } from "./textGeneration.ts"; -import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./adapter.ts"; +import type { PiAdapterV2DriverEnv } from "./adapter.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { buildInitialPiProviderSnapshot, @@ -120,22 +121,30 @@ export const PiDriver: ProviderDriver = { ), ); - const orchestrationAdapter = yield* PiAdapterV2Driver.create({ + const orchestrationAdapter = yield* makeLazyAdapter( instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, + DRIVER_KIND, + Effect.promise(() => import("./adapter.ts")).pipe( + Effect.flatMap(({ PiAdapterV2Driver }) => + PiAdapterV2Driver.create({ instanceId, - detail: "Failed to build Pi orchestration adapter.", - cause, - }), + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Pi orchestration adapter.", + cause, + }), + ), + ), + ), ), ); const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv); diff --git a/packages/shared/package.json b/packages/shared/package.json index c8098ab0fa4e8..9130bc8383952 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -482,6 +482,10 @@ "./PtyAdapter": { "types": "./src/PtyAdapter.ts", "import": "./src/PtyAdapter.ts" + }, + "./lazyService": { + "types": "./src/lazyService.ts", + "import": "./src/lazyService.ts" } }, "scripts": { diff --git a/packages/shared/src/lazyService.ts b/packages/shared/src/lazyService.ts new file mode 100644 index 0000000000000..a88a57ca5b60b --- /dev/null +++ b/packages/shared/src/lazyService.ts @@ -0,0 +1,14 @@ +import * as Effect from "effect/Effect"; +import * as Scope from "effect/Scope"; +import * as Fiber from "effect/Fiber"; + +/** Deferred acquisition keeps services and finalizers in the application scope. */ +export const cacheInOwnerScope = (make: Effect.Effect) => + Effect.gen(function* () { + const context = yield* Effect.context(); + const scope = yield* Scope.Scope; + const fiber = yield* Effect.cached( + make.pipe(Effect.provideContext(context), Effect.forkIn(scope)), + ); + return fiber.pipe(Effect.flatMap(Fiber.join)); + }); diff --git a/packages/source-control-github/src/server/GitHubApi.ts b/packages/source-control-github/src/server/GitHubApi.ts index 111281a0484ee..8e32b01a60ee2 100644 --- a/packages/source-control-github/src/server/GitHubApi.ts +++ b/packages/source-control-github/src/server/GitHubApi.ts @@ -1,26 +1,15 @@ -import * as Clock from "effect/Clock"; +import { cacheInOwnerScope } from "@t3tools/shared/lazyService"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; -import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"; -import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubQuota from "./GitHubQuota.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -const DEFAULT_TIMEOUT = Duration.seconds(30); -const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; -/** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */ -const CONCURRENCY = 8; -const API_VERSION = "2022-11-28"; - /** * A credential already verified for one host. Every request made under it must target that * host, so a page cannot read one account's data with another's token mid-flight. Server-local: @@ -178,407 +167,19 @@ export function gitHubApiUrls(host: string): { readonly rest: string; readonly g return { rest: `https://${normalized}/api/v3`, graphql: `https://${normalized}/api/graphql` }; } -/** GraphQL documents longer than this are cut in traces; the hash still identifies them. */ -const TRACED_QUERY_MAX_CHARS = 4_000; - -/** A stable short id for a GraphQL document, so traces group by query without its text. */ -function queryHash(query: string): string { - let hash = 0x811c9dc5; - for (let index = 0; index < query.length; index++) { - hash ^= query.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, "0"); -} - -/** The numeric rate-limit headers GitHub sends, as span attributes. */ -function rateLimitAttributes( - headers: Readonly>, -): Record { - const attributes: Record = {}; - for (const [header, name] of [ - ["x-ratelimit-limit", "github.ratelimit.limit"], - ["x-ratelimit-remaining", "github.ratelimit.remaining"], - ["x-ratelimit-used", "github.ratelimit.used"], - ["x-ratelimit-reset", "github.ratelimit.reset"], - ] as const) { - const value = Number(headers[header]); - if (headers[header] !== undefined && Number.isFinite(value)) attributes[name] = value; - } - const resource = headers["x-ratelimit-resource"]; - if (resource !== undefined) attributes["github.ratelimit.resource"] = resource; - return attributes; -} - -/** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ -function retryAtFrom( - headers: Readonly>, - now: number, -): number | undefined { - const fromRetryAfter = SourceControlRateLimit.retryAtFromHeader(headers["retry-after"], now); - if (fromRetryAfter !== undefined) return fromRetryAfter; - const reset = Number(headers["x-ratelimit-reset"]) * 1_000; - return Number.isFinite(reset) && reset > now ? reset : undefined; -} - -const decodeGraphQlErrors = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - errors: Schema.NonEmptyArray( - Schema.Struct({ - type: Schema.optional(Schema.String), - message: Schema.optional(Schema.String), - }), - ), - }), - ), -); - -/** A REST failure body: `{ message, errors: [{ message } | { resource, field, code }] }`. */ -const decodeRestErrors = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - message: Schema.optional(Schema.String), - errors: Schema.optional( - Schema.Array( - Schema.Union([ - Schema.String, - Schema.Struct({ - message: Schema.optional(Schema.String), - field: Schema.optional(Schema.String), - code: Schema.optional(Schema.String), - }), - ]), - ), - ), - }), - ), -); - -function restErrorMessages(body: string): ReadonlyArray | undefined { - return Option.match(decodeRestErrors(body), { - onNone: () => undefined, - onSome: (decoded) => { - const details = (decoded.errors ?? []).flatMap((error) => - typeof error === "string" - ? [error] - : error.message !== undefined - ? [error.message] - : error.field !== undefined && error.code !== undefined - ? [`${error.field} ${error.code}`] - : [], - ); - const messages = [...(decoded.message === undefined ? [] : [decoded.message]), ...details]; - return messages.length > 0 ? messages : undefined; - }, - }); -} - -/** What one GitHub answer means, decided once from its status, headers and body. */ -type Answer = Data.TaggedEnum<{ - Ok: {}; - RateLimited: {}; - Unauthorized: {}; - NotFound: {}; - Failed: { readonly messages: ReadonlyArray | undefined }; -}>; -const Answer = Data.taggedEnum(); - -/** - * GitHub reports a failed GraphQL document with HTTP 200 and an `errors` list, so a GraphQL body - * is read for its error types as well as its status. `gh api graphql` failed those too, and - * callers rely on that to fall back to narrower reads. - */ -function classify(input: { - readonly status: number; - readonly headers: Readonly>; - readonly body: string; - readonly graphql: boolean; - readonly acceptNotModified: boolean; -}): Answer { - const { status, headers, body } = input; - const errors = input.graphql - ? Option.getOrUndefined(decodeGraphQlErrors(body))?.errors - : undefined; - const types = errors?.flatMap((error) => (error.type === undefined ? [] : [error.type])) ?? []; - const messages = errors?.flatMap((error) => (error.message === undefined ? [] : [error.message])); - if ( - status === 429 || - types.includes("RATE_LIMITED") || - // An exhausted GraphQL quota can answer HTTP 200 with an untyped "API rate limit already - // exceeded" error; the headers say the same thing. - (input.graphql && errors !== undefined && headers["x-ratelimit-remaining"] === "0") || - (input.graphql && - messages !== undefined && - messages.some((message) => /rate limit (already )?exceeded/i.test(message))) || - (status === 403 && - (headers["x-ratelimit-remaining"] === "0" || - headers["retry-after"] !== undefined || - /rate limit/i.test(body))) - ) { - return Answer.RateLimited(); - } - if (status === 401) return Answer.Unauthorized(); - if ( - status === 404 || - (errors !== undefined && errors.every((error) => error.type === "NOT_FOUND")) - ) { - return Answer.NotFound(); - } - if (errors !== undefined) return Answer.Failed({ messages }); - if ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) { - return Answer.Ok(); - } - return Answer.Failed({ messages: input.graphql ? undefined : restErrorMessages(body) }); -} - -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const credentials = yield* GitHubCredentials.GitHubCredentials; - const quota = yield* GitHubQuota.GitHubQuota; - const limits = yield* SourceControlRateLimit.SourceControlRateLimit; - const gate = yield* Semaphore.make(CONCURRENCY); - - const credential: GitHubApi["Service"]["credential"] = Effect.fn("GitHubApi.credential")( - function* (host) { - const normalized = normalizeHost(host); - const pinned = yield* PinnedGitHubCredential; - if (pinned !== null) { - // A pinned page only ever talks to the host it verified. - if (pinned.host !== normalized) { - return yield* new GitHubApiAuthenticationError({ - host: normalized, - operation: "credential", - }); - } - return { token: pinned.token, fingerprint: pinned.credentialFingerprint }; - } - const held = yield* credentials.get(normalized); - return { token: held.token, fingerprint: held.fingerprint }; - }, + const live = yield* cacheInOwnerScope( + Effect.promise(() => import("./GitHubApiLive.ts")).pipe( + Effect.flatMap((module) => module.make), + ), ); - - /** - * Sends one request under the host's rate-limit pause and fails a refusal with the error that - * says why. `onSuccess` sees the answer only once it is known to be one. - */ - const send = Effect.fn("GitHubApi.send")(function* (input: { - readonly host: string; - readonly operation: string; - readonly request: HttpClientRequest.HttpClientRequest; - readonly maxResponseBytes: number; - readonly timeout?: Duration.Input | undefined; - readonly allowReserve: boolean; - readonly acceptNotModified: boolean; - /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ - readonly graphql?: boolean; - }) { - const host = normalizeHost(input.host); - // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. - yield* Effect.annotateCurrentSpan({ - "github.host": host, - "github.operation": input.operation, - "github.kind": input.graphql === true ? "graphql" : "rest", - "http.request.method": input.request.method, - "url.path": new URL(input.request.url).pathname, - }); - const { token, fingerprint } = yield* credential(host); - const scope = yield* SourceControlRateLimit.CredentialScope; - // REST spends `core` and GraphQL its own quota, each with its own reserve. Every REST path - // this reads today is `core`; a `search/` read would need its own resource here. A refusal - // still pauses the whole host, the key PullRequestService records its own backoff under. - const resource = input.graphql === true ? "graphql" : "core"; - const key = { provider: "github" as const, host }; - const run = Effect.gen(function* () { - const lease = yield* quota - .admit(host, resource, { allowReserve: input.allowReserve }) - .pipe( - Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), - ) - .pipe( - Effect.tapError((paused) => - Effect.annotateCurrentSpan({ - "github.paused": true, - "github.retry_at": paused.retryAt, - }), - ), - ); - // One deadline covers the headers and the body: a host that answers headers and then stalls - // must not hold a slot of the shared gate for undici's own five-minute body timeout. - const { response, collected } = yield* Effect.gen(function* () { - const response: HttpClientResponse.HttpClientResponse = yield* httpClient - .execute( - input.request.pipe( - HttpClientRequest.bearerToken(Redacted.value(token)), - HttpClientRequest.setHeaders({ - "x-github-api-version": API_VERSION, - "user-agent": "t3code", - }), - ), - ) - .pipe( - // `GitHubApi.send` is the request's span. The client's own span would add `url.full` - // and `url.query`, which can carry SHAs and branch names, so it is off for GitHub. - Effect.provideService(HttpClient.TracerDisabledWhen, () => true), - ); - const collected = yield* collectUint8StreamText({ - stream: response.stream, - maxBytes: input.maxResponseBytes, - }).pipe( - // 204, 304 and many refusals carry no body at all, which is an empty answer. - Effect.catchIf( - (error) => error.reason._tag === "EmptyBodyError", - () => Effect.succeed({ text: "", truncated: false, invalidUtf8: false }), - ), - ); - return { response, collected }; - }).pipe( - Effect.timeout(input.timeout ?? DEFAULT_TIMEOUT), - Effect.mapError( - (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }), - ), - ); - const headers = response.headers; - const status = response.status; - yield* quota.observe(host, headers); - yield* Effect.annotateCurrentSpan({ - "http.response.status_code": status, - ...rateLimitAttributes(headers), - }); - const context = { host, operation: input.operation }; - return yield* Answer.$match( - classify({ - status, - headers, - body: collected.text, - graphql: input.graphql === true, - acceptNotModified: input.acceptNotModified, - }), - { - Ok: () => - limits.recordSuccess({ ...key, lease }).pipe( - Effect.as({ - status, - headers, - body: collected.text, - truncated: collected.truncated, - invalidUtf8: collected.invalidUtf8, - }), - ), - RateLimited: () => - Effect.gen(function* () { - const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); - yield* limits.recordRateLimit({ ...key, lease, retryAt }); - yield* Effect.annotateCurrentSpan({ - "github.rate_limited": true, - ...(retryAt === undefined ? {} : { "github.retry_at": retryAt }), - }); - return yield* new GitHubApiRateLimitError({ - ...context, - ...(retryAt === undefined ? {} : { retryAt }), - }); - }), - // The source may hold a newer token than the one that was refused. - Unauthorized: () => - credentials - .invalidate(host) - .pipe(Effect.andThen(Effect.fail(new GitHubApiAuthenticationError(context)))), - NotFound: () => Effect.fail(new GitHubApiNotFoundError(context)), - Failed: ({ messages }) => - Effect.fail( - new GitHubApiResponseError({ - ...context, - status, - ...(messages === undefined ? {} : { githubErrors: messages }), - }), - ), - }, - ); - }); - // The rate-limit scope follows the credential, so a pause on one account never blocks another. - return yield* gate - .withPermit(run) - .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint)); + return GitHubApi.of({ + graphql: (input) => live.pipe(Effect.flatMap((value) => value.graphql(input))), + rest: (input) => live.pipe(Effect.flatMap((value) => value.rest(input))), + credential: (host) => live.pipe(Effect.flatMap((value) => value.credential(host))), }); - - const rest: GitHubApi["Service"]["rest"] = (input) => { - const url = `${gitHubApiUrls(input.host).rest}/${input.path.replace(/^\/+/, "")}`; - const base = HttpClientRequest.make(input.method ?? "GET")(url).pipe( - HttpClientRequest.setHeader("accept", input.accept ?? "application/vnd.github+json"), - ); - const withEtag = - input.ifNoneMatch === undefined - ? base - : base.pipe(HttpClientRequest.setHeader("if-none-match", input.ifNoneMatch)); - const request = - input.body === undefined - ? withEtag - : withEtag.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)); - return SourceControlRateLimit.Interactive.pipe( - Effect.flatMap((interactive) => - send({ - host: input.host, - operation: input.operation, - request, - maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, - timeout: input.timeout, - allowReserve: input.allowReserve ?? interactive, - acceptNotModified: input.ifNoneMatch !== undefined, - }), - ), - ); - }; - - const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")( - function* (input) { - const host = normalizeHost(input.host); - const { fingerprint } = yield* credential(host); - const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint; - const allowReserve = input.allowReserve ?? (yield* SourceControlRateLimit.Interactive); - // The document, never its variables: user text (bodies, search terms) travels as variables. - yield* Effect.annotateCurrentSpan({ - "github.operation": input.operation, - "github.graphql.query_hash": queryHash(input.query), - "github.graphql.query": - input.query.length > TRACED_QUERY_MAX_CHARS - ? `${input.query.slice(0, TRACED_QUERY_MAX_CHARS)}…` - : input.query, - }); - return yield* Effect.gen(function* () { - const response = yield* send({ - host, - operation: input.operation, - request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bodyJsonUnsafe({ - query: input.query, - variables: input.variables ?? {}, - }), - ), - maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, - allowReserve, - acceptNotModified: false, - graphql: true, - }); - // A body cut at the byte cap hides any `errors` past the cut and cannot be decoded. - if (response.truncated) { - return yield* new GitHubApiResponseError({ - host, - operation: input.operation, - status: response.status, - }); - } - return response.body; - }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); - }, - ); - - return GitHubApi.of({ graphql, rest, credential }); }); - export const layer = Layer.effect(GitHubApi, make); - /** * The transport with the credential, quota reserve and rate-limit pause it reads through. An * application builds this once so every GitHub reader shares one view of each quota per host. diff --git a/packages/source-control-github/src/server/GitHubApiLive.ts b/packages/source-control-github/src/server/GitHubApiLive.ts new file mode 100644 index 0000000000000..b6ab355d3bc9f --- /dev/null +++ b/packages/source-control-github/src/server/GitHubApiLive.ts @@ -0,0 +1,432 @@ +import * as Clock from "effect/Clock"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"; + +import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubQuota from "./GitHubQuota.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; + +import { + GitHubApi, + PinnedGitHubCredential, + gitHubApiUrls, + GitHubApiRequestError, + GitHubApiAuthenticationError, + GitHubApiRateLimitError, + GitHubApiNotFoundError, + GitHubApiResponseError, +} from "./GitHubApi.ts"; +const DEFAULT_TIMEOUT = Duration.seconds(30); +const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */ +const CONCURRENCY = 8; +const API_VERSION = "2022-11-28"; + +function normalizeHost(host: string): string { + return host.trim().toLowerCase(); +} +/** GraphQL documents longer than this are cut in traces; the hash still identifies them. */ +const TRACED_QUERY_MAX_CHARS = 4_000; + +/** A stable short id for a GraphQL document, so traces group by query without its text. */ +function queryHash(query: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < query.length; index++) { + hash ^= query.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +/** The numeric rate-limit headers GitHub sends, as span attributes. */ +function rateLimitAttributes( + headers: Readonly>, +): Record { + const attributes: Record = {}; + for (const [header, name] of [ + ["x-ratelimit-limit", "github.ratelimit.limit"], + ["x-ratelimit-remaining", "github.ratelimit.remaining"], + ["x-ratelimit-used", "github.ratelimit.used"], + ["x-ratelimit-reset", "github.ratelimit.reset"], + ] as const) { + const value = Number(headers[header]); + if (headers[header] !== undefined && Number.isFinite(value)) attributes[name] = value; + } + const resource = headers["x-ratelimit-resource"]; + if (resource !== undefined) attributes["github.ratelimit.resource"] = resource; + return attributes; +} + +/** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ +function retryAtFrom( + headers: Readonly>, + now: number, +): number | undefined { + const fromRetryAfter = SourceControlRateLimit.retryAtFromHeader(headers["retry-after"], now); + if (fromRetryAfter !== undefined) return fromRetryAfter; + const reset = Number(headers["x-ratelimit-reset"]) * 1_000; + return Number.isFinite(reset) && reset > now ? reset : undefined; +} + +const decodeGraphQlErrors = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + errors: Schema.NonEmptyArray( + Schema.Struct({ + type: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), + ), + }), + ), +); + +/** A REST failure body: `{ message, errors: [{ message } | { resource, field, code }] }`. */ +const decodeRestErrors = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + message: Schema.optional(Schema.String), + errors: Schema.optional( + Schema.Array( + Schema.Union([ + Schema.String, + Schema.Struct({ + message: Schema.optional(Schema.String), + field: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), + }), + ]), + ), + ), + }), + ), +); + +function restErrorMessages(body: string): ReadonlyArray | undefined { + return Option.match(decodeRestErrors(body), { + onNone: () => undefined, + onSome: (decoded) => { + const details = (decoded.errors ?? []).flatMap((error) => + typeof error === "string" + ? [error] + : error.message !== undefined + ? [error.message] + : error.field !== undefined && error.code !== undefined + ? [`${error.field} ${error.code}`] + : [], + ); + const messages = [...(decoded.message === undefined ? [] : [decoded.message]), ...details]; + return messages.length > 0 ? messages : undefined; + }, + }); +} + +/** What one GitHub answer means, decided once from its status, headers and body. */ +type Answer = Data.TaggedEnum<{ + Ok: {}; + RateLimited: {}; + Unauthorized: {}; + NotFound: {}; + Failed: { readonly messages: ReadonlyArray | undefined }; +}>; +const Answer = Data.taggedEnum(); + +/** + * GitHub reports a failed GraphQL document with HTTP 200 and an `errors` list, so a GraphQL body + * is read for its error types as well as its status. `gh api graphql` failed those too, and + * callers rely on that to fall back to narrower reads. + */ +function classify(input: { + readonly status: number; + readonly headers: Readonly>; + readonly body: string; + readonly graphql: boolean; + readonly acceptNotModified: boolean; +}): Answer { + const { status, headers, body } = input; + const errors = input.graphql + ? Option.getOrUndefined(decodeGraphQlErrors(body))?.errors + : undefined; + const types = errors?.flatMap((error) => (error.type === undefined ? [] : [error.type])) ?? []; + const messages = errors?.flatMap((error) => (error.message === undefined ? [] : [error.message])); + if ( + status === 429 || + types.includes("RATE_LIMITED") || + // An exhausted GraphQL quota can answer HTTP 200 with an untyped "API rate limit already + // exceeded" error; the headers say the same thing. + (input.graphql && errors !== undefined && headers["x-ratelimit-remaining"] === "0") || + (input.graphql && + messages !== undefined && + messages.some((message) => /rate limit (already )?exceeded/i.test(message))) || + (status === 403 && + (headers["x-ratelimit-remaining"] === "0" || + headers["retry-after"] !== undefined || + /rate limit/i.test(body))) + ) { + return Answer.RateLimited(); + } + if (status === 401) return Answer.Unauthorized(); + if ( + status === 404 || + (errors !== undefined && errors.every((error) => error.type === "NOT_FOUND")) + ) { + return Answer.NotFound(); + } + if (errors !== undefined) return Answer.Failed({ messages }); + if ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) { + return Answer.Ok(); + } + return Answer.Failed({ messages: input.graphql ? undefined : restErrorMessages(body) }); +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const credentials = yield* GitHubCredentials.GitHubCredentials; + const quota = yield* GitHubQuota.GitHubQuota; + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const gate = yield* Semaphore.make(CONCURRENCY); + + const credential: GitHubApi["Service"]["credential"] = Effect.fn("GitHubApi.credential")( + function* (host) { + const normalized = normalizeHost(host); + const pinned = yield* PinnedGitHubCredential; + if (pinned !== null) { + // A pinned page only ever talks to the host it verified. + if (pinned.host !== normalized) { + return yield* new GitHubApiAuthenticationError({ + host: normalized, + operation: "credential", + }); + } + return { token: pinned.token, fingerprint: pinned.credentialFingerprint }; + } + const held = yield* credentials.get(normalized); + return { token: held.token, fingerprint: held.fingerprint }; + }, + ); + + /** + * Sends one request under the host's rate-limit pause and fails a refusal with the error that + * says why. `onSuccess` sees the answer only once it is known to be one. + */ + const send = Effect.fn("GitHubApi.send")(function* (input: { + readonly host: string; + readonly operation: string; + readonly request: HttpClientRequest.HttpClientRequest; + readonly maxResponseBytes: number; + readonly timeout?: Duration.Input | undefined; + readonly allowReserve: boolean; + readonly acceptNotModified: boolean; + /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ + readonly graphql?: boolean; + }) { + const host = normalizeHost(input.host); + // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. + yield* Effect.annotateCurrentSpan({ + "github.host": host, + "github.operation": input.operation, + "github.kind": input.graphql === true ? "graphql" : "rest", + "http.request.method": input.request.method, + "url.path": new URL(input.request.url).pathname, + }); + const { token, fingerprint } = yield* credential(host); + const scope = yield* SourceControlRateLimit.CredentialScope; + // REST spends `core` and GraphQL its own quota, each with its own reserve. Every REST path + // this reads today is `core`; a `search/` read would need its own resource here. A refusal + // still pauses the whole host, the key PullRequestService records its own backoff under. + const resource = input.graphql === true ? "graphql" : "core"; + const key = { provider: "github" as const, host }; + const run = Effect.gen(function* () { + const lease = yield* quota + .admit(host, resource, { allowReserve: input.allowReserve }) + .pipe( + Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), + ) + .pipe( + Effect.tapError((paused) => + Effect.annotateCurrentSpan({ + "github.paused": true, + "github.retry_at": paused.retryAt, + }), + ), + ); + // One deadline covers the headers and the body: a host that answers headers and then stalls + // must not hold a slot of the shared gate for undici's own five-minute body timeout. + const { response, collected } = yield* Effect.gen(function* () { + const response: HttpClientResponse.HttpClientResponse = yield* httpClient + .execute( + input.request.pipe( + HttpClientRequest.bearerToken(Redacted.value(token)), + HttpClientRequest.setHeaders({ + "x-github-api-version": API_VERSION, + "user-agent": "t3code", + }), + ), + ) + .pipe( + // `GitHubApi.send` is the request's span. The client's own span would add `url.full` + // and `url.query`, which can carry SHAs and branch names, so it is off for GitHub. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + ); + const collected = yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: input.maxResponseBytes, + }).pipe( + // 204, 304 and many refusals carry no body at all, which is an empty answer. + Effect.catchIf( + (error) => error.reason._tag === "EmptyBodyError", + () => Effect.succeed({ text: "", truncated: false, invalidUtf8: false }), + ), + ); + return { response, collected }; + }).pipe( + Effect.timeout(input.timeout ?? DEFAULT_TIMEOUT), + Effect.mapError( + (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }), + ), + ); + const headers = response.headers; + const status = response.status; + yield* quota.observe(host, headers); + yield* Effect.annotateCurrentSpan({ + "http.response.status_code": status, + ...rateLimitAttributes(headers), + }); + const context = { host, operation: input.operation }; + return yield* Answer.$match( + classify({ + status, + headers, + body: collected.text, + graphql: input.graphql === true, + acceptNotModified: input.acceptNotModified, + }), + { + Ok: () => + limits.recordSuccess({ ...key, lease }).pipe( + Effect.as({ + status, + headers, + body: collected.text, + truncated: collected.truncated, + invalidUtf8: collected.invalidUtf8, + }), + ), + RateLimited: () => + Effect.gen(function* () { + const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); + yield* limits.recordRateLimit({ ...key, lease, retryAt }); + yield* Effect.annotateCurrentSpan({ + "github.rate_limited": true, + ...(retryAt === undefined ? {} : { "github.retry_at": retryAt }), + }); + return yield* new GitHubApiRateLimitError({ + ...context, + ...(retryAt === undefined ? {} : { retryAt }), + }); + }), + // The source may hold a newer token than the one that was refused. + Unauthorized: () => + credentials + .invalidate(host) + .pipe(Effect.andThen(Effect.fail(new GitHubApiAuthenticationError(context)))), + NotFound: () => Effect.fail(new GitHubApiNotFoundError(context)), + Failed: ({ messages }) => + Effect.fail( + new GitHubApiResponseError({ + ...context, + status, + ...(messages === undefined ? {} : { githubErrors: messages }), + }), + ), + }, + ); + }); + // The rate-limit scope follows the credential, so a pause on one account never blocks another. + return yield* gate + .withPermit(run) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint)); + }); + + const rest: GitHubApi["Service"]["rest"] = (input) => { + const url = `${gitHubApiUrls(input.host).rest}/${input.path.replace(/^\/+/, "")}`; + const base = HttpClientRequest.make(input.method ?? "GET")(url).pipe( + HttpClientRequest.setHeader("accept", input.accept ?? "application/vnd.github+json"), + ); + const withEtag = + input.ifNoneMatch === undefined + ? base + : base.pipe(HttpClientRequest.setHeader("if-none-match", input.ifNoneMatch)); + const request = + input.body === undefined + ? withEtag + : withEtag.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)); + return SourceControlRateLimit.Interactive.pipe( + Effect.flatMap((interactive) => + send({ + host: input.host, + operation: input.operation, + request, + maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + timeout: input.timeout, + allowReserve: input.allowReserve ?? interactive, + acceptNotModified: input.ifNoneMatch !== undefined, + }), + ), + ); + }; + + const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")( + function* (input) { + const host = normalizeHost(input.host); + const { fingerprint } = yield* credential(host); + const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint; + const allowReserve = input.allowReserve ?? (yield* SourceControlRateLimit.Interactive); + // The document, never its variables: user text (bodies, search terms) travels as variables. + yield* Effect.annotateCurrentSpan({ + "github.operation": input.operation, + "github.graphql.query_hash": queryHash(input.query), + "github.graphql.query": + input.query.length > TRACED_QUERY_MAX_CHARS + ? `${input.query.slice(0, TRACED_QUERY_MAX_CHARS)}…` + : input.query, + }); + return yield* Effect.gen(function* () { + const response = yield* send({ + host, + operation: input.operation, + request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bodyJsonUnsafe({ + query: input.query, + variables: input.variables ?? {}, + }), + ), + maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + allowReserve, + acceptNotModified: false, + graphql: true, + }); + // A body cut at the byte cap hides any `errors` past the cut and cannot be decoded. + if (response.truncated) { + return yield* new GitHubApiResponseError({ + host, + operation: input.operation, + status: response.status, + }); + } + return response.body; + }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); + }, + ); + + return GitHubApi.of({ graphql, rest, credential }); +}); diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 961106c58acce..c4232e1a49eec 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -503,17 +503,16 @@ it.effect( }), ), ); - const cli = yield* GitHubPullRequestApi.make.pipe( - Effect.provide( - GitHubApi.layer.pipe( - Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provide(GitHubQuota.layer), - Layer.provide(SourceControlRateLimit.layer), - Layer.merge(TestSourceControlHost.layer()), - Layer.provideMerge(NodeServices.layer), - ), + const dependencies = yield* Layer.build( + GitHubApi.layer.pipe( + Layer.provide(Layer.mergeAll(credentials, http)), + Layer.provide(GitHubQuota.layer), + Layer.provide(SourceControlRateLimit.layer), + Layer.merge(TestSourceControlHost.layer()), + Layer.provideMerge(NodeServices.layer), ), ); + const cli = yield* GitHubPullRequestApi.make.pipe(Effect.provideContext(dependencies)); const input = { cwd: "/repo", host: "github.com" }; const first = yield* cli.withVerifiedCredential(input, (identity) => Effect.gen(function* () { diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index b8c7fe2276889..6e03fa96ba693 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -1,142 +1,48 @@ -import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; -import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; -import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; -import * as Cause from "effect/Cause"; +import { cacheInOwnerScope } from "@t3tools/shared/lazyService"; +import type { makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; +import type { GitHubStackActionError } from "./githubStackActions.ts"; import * as Context from "effect/Context"; -import * as Clock from "effect/Clock"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Request from "effect/Request"; -import * as RequestResolver from "effect/RequestResolver"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; -import { - resolvePullRequestAuthorFilter, - PositiveInt, - TrimmedNonEmptyString, - type PullRequestAction, - type PullRequestStackHead, - type PullRequestActor, - type PullRequestComment, - type PullRequestCommit, - type PullRequestFileViewed, - type PullRequestInvolvement, - type PullRequestListFilters, - type PullRequestListState, - type PullRequestMergeMethod, - type PullRequestOmittedFileStat, - type PullRequestReaction, - type PullRequestReactionContent, - type PullRequestReviewCommentDraft, - type PullRequestReviewVerdict, - type PullRequestReviewerCandidateList, - type PullRequestReviewerKind, - type PullRequestLabelCandidateList, - type PullRequestThreadCommentsResult, - type PullRequestUpdateMethod, - type PullRequestPreview, +import type { + PullRequestAction, + PullRequestStackHead, + PullRequestFileViewed, + PullRequestInvolvement, + PullRequestListFilters, + PullRequestListState, + PullRequestMergeMethod, + PullRequestOmittedFileStat, + PullRequestReactionContent, + PullRequestReviewCommentDraft, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestLabelCandidateList, + PullRequestThreadCommentsResult, + PullRequestUpdateMethod, + PullRequestPreview, } from "@t3tools/contracts"; - -import * as GitHubApi from "./GitHubApi.ts"; -import { readGraphQlPages } from "./githubGraphQl.ts"; -import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; -import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -import { - ACTOR_AVATARS_GRAPHQL_QUERY, - ADD_REACTION_GRAPHQL_MUTATION, - buildReviewSubmission, - buildReviewerRequest, - buildSetFilesViewedGraphQlMutation, - decodeActorAvatarsJson, - decodePullRequestActivityJson, - decodePullRequestCheckContextsJson, - decodePullRequestCoreJson, - decodeCommitFilesJson, - pullRequestCheckContextsGraphQlQuery, - pullRequestChecksFromContexts, - pullRequestListGraphQlQuery, - pullRequestSummaryGraphQlQuery, - PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, - PULL_REQUEST_HEADS_GRAPHQL_QUERY, - decodeWorkflowRunsJson, - pullRequestCoreGraphQlQuery, - type GitHubPullRequestCore, - type GitHubPullRequestSummary, - type GitHubPullRequestWatchFingerprint, - decodePullRequestPreviewJson, - PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, - decodePullRequestFilesJson, - decodePullRequestFilesViewedJson, - decodePullRequestHeadsJson, - decodePullRequestListJson, - decodePullRequestNodeIdJson, - decodePullRequestSearchJson, - decodePullRequestStacksJson, - decodePullRequestStatsJson, - decodePullRequestSummariesJson, - decodePullRequestWatchFingerprintsJson, - decodeReactionSubjectScopeJson, - decodeReviewerCandidatesJson, - decodeLabelCandidatesJson, - buildLabelRequest, - LABEL_CANDIDATES_GRAPHQL_QUERY, - decodeReviewDismissalsJson, - decodeReviewThreadCommentsJson, - decodeReviewThreadsJson, - buildPullRequestStatsGraphQlQuery, - buildPullRequestSummariesGraphQlQuery, - buildPullRequestWatchFingerprintsGraphQlQuery, - buildPullRequestStackMembershipsGraphQlQuery, - decodePullRequestStackMembershipsJson, - pullRequestSearchGraphQlQuery, - PULL_REQUEST_SEARCH_MAX_ROWS, - PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, - PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, - REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, - REMOVE_REACTION_GRAPHQL_MUTATION, - REVERT_PULL_REQUEST_GRAPHQL_MUTATION, - gitHubReactionContent, - RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, - REVIEWER_CANDIDATES_GRAPHQL_QUERY, - REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, - REVIEW_DISMISSALS_GRAPHQL_QUERY, - REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, - REVIEW_THREADS_GRAPHQL_QUERY, - reviewThreadConversation, - UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, - UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION, - UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, - UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, - VIEWER_PERMISSIONS_GRAPHQL_QUERY, - decodeViewerPermissionsJson, - type GitHubPullRequestActivity, - type GitHubPullRequestActivityPage, - type GitHubWorkflowRunPage, - type GitHubPullRequestHead, - type GitHubPullRequestListItem, - type GitHubPullRequestSearchItem, - type GitHubPullRequestStack, - type GitHubReviewThreadComments, - type GitHubRepositoryAccess, - type GitHubWorkflowRunApproval, - type GitHubReviewThreadEntry, - type GitHubReviewThreadPage, - type GitHubViewerAccess, +import type * as GitHubApi from "./GitHubApi.ts"; +import type * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import type { + GitHubPullRequestCore, + GitHubPullRequestWatchFingerprint, + GitHubPullRequestActivity, + GitHubPullRequestListItem, + GitHubPullRequestSearchItem, + GitHubPullRequestStack, + GitHubReviewThreadComments, + GitHubRepositoryAccess, + GitHubWorkflowRunApproval, + GitHubViewerAccess, } from "./gitHubPullRequestJson.ts"; import type { ProviderChangeRequestSummary, ProviderListCursor, } from "@t3tools/source-control-core/server/PullRequestProvider"; -/** - * Names the read that produced unusable output, so a failure reports the call it came from - * rather than borrowing another operation's message. - */ export class GitHubPullRequestReadError extends Schema.TaggedError()( "GitHubPullRequestReadError", { @@ -328,57 +234,8 @@ export type GitHubPullRequestApiError = | GitHubViewerLoginUnavailableError | GitHubPullRequestUpdatedAtUnavailableError; -/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ -const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; -const DIFF_TIMEOUT = Duration.seconds(60); -/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ -const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; - -/** What `gh pr list --state` asked a repository's own list for, without search. */ -const LIST_STATES: Record> = { - all: ["OPEN", "CLOSED", "MERGED"], - open: ["OPEN"], - // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out - // locally by `matchesUnsortedListing`. - closed: ["CLOSED", "MERGED"], - merged: ["MERGED"], -}; - -interface GitHubPullRequestListPage { - readonly items: ReadonlyArray; - readonly rawCount: number; - readonly endCursor: string | null; -} - -/** A search-free fallback may scan older rows for local filters, but never the whole repository. */ -const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; - -/** What the files API serves at most in one response, which is what one slice is made of. */ -const DIFF_FILES_PAGE_SIZE = 100; -/** - * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL - * budget per page, against a change request nobody reviews in one sitting past the first few - * hundred files: beyond this the read stops and says it was cut short. - */ -const FILES_VIEWED_MAX_PAGES = 5; - -/** - * How many pull requests' node ids are remembered at once. A long-lived server sees far more of - * them than a reader ever has open, and least recently used rather than first in: a listing - * walking cold pull requests must not evict the review being ticked through. - */ export const NODE_ID_CACHE_CAPACITY = 128; -/** - * Pages of review threads to follow before the conversation is reported as truncated. GitHub - * serves a hundred threads a page, so this is a thousand threads — past anything a pull request - * a person is reading has, and short of walking a repository-sized conversation forever. - */ -const REVIEW_THREAD_PAGES = 10; - -/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ -const CHECK_CONTEXT_PAGES = 10; - export interface GitHubPullRequestListBatch { readonly items: ReadonlyArray; readonly truncated: boolean; @@ -393,41 +250,6 @@ export interface GitHubPullRequestStat { readonly deletions: number; } -/** - * Aliased lookups per request, and requests at once. Measured over a hundred rows: one request - * carrying all hundred takes ~5.2s, four of twenty-five in parallel ~2.1s. - */ -const STAT_ALIASES_PER_REQUEST = 25; -const STAT_REQUEST_CONCURRENCY = 4; -/** - * How long a summary read waits for company. The background sync asks for every linked pull - * request at once, and each read reaches the resolver after its own cache check, so a batch - * needs a moment longer than one scheduler tick to gather them. - */ -const SUMMARY_BATCH_WINDOW = "10 millis"; - -class PullRequestSummaryRead extends Request.Class< - { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }, - ProviderChangeRequestSummary, - GitHubPullRequestApiError -> {} - -class PullRequestWatchFingerprintRead extends Request.Class< - { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - }, - GitHubPullRequestWatchFingerprint | null, - GitHubPullRequestApiError -> {} - export interface GitHubPullRequestSearchBatch { /** Rows across every repository asked for, newest update first, each naming its own. */ readonly items: ReadonlyArray; @@ -795,2063 +617,75 @@ export class GitHubPullRequestApi extends Context.Service< } >()("@t3tools/source-control-github/server/GitHubPullRequestApi") {} -/** - * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. - * The host is not read off the identity: it travels alongside it, because the identity a - * project records is the path below its host and never names the host itself. - */ -function parseRepositorySelector(value: string): { - readonly owner: string; - readonly name: string; -} { - const parts = value.trim().split("/").filter(Boolean); - return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" }; -} - -/** - * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor - * arrives from the reader as a string and goes straight into a request path, so it is parsed - * rather than trusted; the length bound keeps a page number out of exponential notation. - */ -function diffCursorPage(cursor: string): number | null { - return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; -} - -/** - * A commit sha arrives from the reader and goes straight into a request path, so it is checked - * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a - * whole sha. - */ -function isCommitSha(value: string): boolean { - return /^[0-9a-f]{7,64}$/i.test(value); -} - -/** - * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole - * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so - * text typed into a search box could widen the very listing it is meant to narrow — inside them - * it is only text. The two characters that could end the phrase early are therefore escaped - * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which - * would let everything after it out of the phrase. - * - * The phrase is one argv element, so nothing in it can become a flag of its own either. - */ -function searchPhrase(query: string): string { - return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; -} - -/** GitHub's own spelling of a review state, which is not the contract's. */ -const REVIEW_QUALIFIERS = { - approved: "approved", - "changes-requested": "changes_requested", - "review-required": "required", - none: "none", -} as const; - -/** - * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the - * one character that could end the quoted value early is dropped rather than escaped: no GitHub - * label or login holds a double quote, so there is nothing to preserve and everything to lose. - */ -function qualifierValue(value: string): string { - return `"${value.replaceAll('"', "").trim()}"`; -} - -function filterQualifiers( - filters: PullRequestListFilters | undefined, - viewer: string, -): ReadonlyArray { - if (filters === undefined) return []; - return [ - // One qualifier per group, its names joined by commas — GitHub's own OR. - ...(filters.labels ?? []).flatMap((group) => - group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`], - ), - ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`), - ...(filters.author === undefined - ? [] - : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]), - ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]), - ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]), - ...(filters.checks === undefined - ? [] - : [`status:${filters.checks === "passing" ? "success" : "failure"}`]), - ]; -} - -/** - * The same narrowings over a row that has already arrived, for the search-free fallback. Every - * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by - * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an - * absent state on purpose — a row with no checks configured, or whose checks are still `pending`, - * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would - * not have surfaced for `status:success` or `status:failure`. - */ -function matchesFilters( - item: GitHubPullRequestListItem, - filters: PullRequestListFilters | undefined, - viewer: string, -): boolean { - if (filters === undefined) return true; - const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); - const holds = (label: string) => labels.has(label.trim().toLowerCase()); - return ( - (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && - (filters.review === undefined || - (filters.review === "none" - ? item.reviewDecision === null - : item.reviewDecision === filters.review)) && - (filters.checks === undefined || item.checksState === filters.checks) && - (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && - (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && - (filters.author === undefined || - item.author?.login.toLowerCase() === - resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) - ); -} - -/** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ -function matchesUnsortedListing( - item: GitHubPullRequestListItem, - input: { - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly filters?: PullRequestListFilters | undefined; - }, -): boolean { - const matchesState = input.state === "all" || item.state === input.state; - const viewer = input.viewer.toLowerCase(); - const matchesInvolvement = - input.involvement === "all" || - (input.involvement === "authored" - ? item.author?.login.toLowerCase() === viewer - : item.hasTeamReviewRequest || - item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer)); - return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer); -} - -/** What a repository selector may hold before it goes into a search as itself. */ -const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; - -/** - * A listing as one GitHub search, across one repository or several: the only way to read a - * whole host in one request, and the only order (`updated`) a continuation can carry on from. - * - * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is - * `is:closed is:unmerged` here. - * - * Null where a repository is not `owner/name`. A name is written into the query as itself, and a - * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its - * own — so an unaddressable one refuses the whole read rather than being escaped into something - * GitHub might still read. - */ -function searchQuery(input: { - readonly repositories: ReadonlyArray; - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly query?: string | undefined; - readonly cursor?: ProviderListCursor | undefined; - readonly filters?: PullRequestListFilters | undefined; -}): string | null { - if (input.repositories.length === 0) return null; - const repositories = input.repositories.map((repository) => repository.trim()); - if (!repositories.every((repository) => SEARCH_REPOSITORY.test(repository))) return null; - const query = input.query?.trim() ?? ""; - return [ - "is:pr", - // "all" is every state, which `is:pr` already is. - ...(input.state === "open" ? ["is:open"] : []), - ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []), - ...(input.state === "merged" ? ["is:merged"] : []), - ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []), - ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), - ...(query.length === 0 ? [] : [searchPhrase(query)]), - // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. - ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), - ...filterQualifiers(input.filters, input.viewer), - // The order the page reads its rows in, and the only order a continuation can carry on from. - "sort:updated-desc", - ...repositories.map((repository) => `repo:${repository}`), - ].join(" "); -} - -const MERGE_MESSAGE_GRAPHQL_QUERY = ` -query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - isMergeQueueEnabled - headRefOid - viewerMergeBodyText(mergeType: $method) - } - } -}`; - -/** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */ -const decodeRevisionRefs = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - sha: Schema.optional(Schema.String), - parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))), - base: Schema.optional(Schema.Struct({ sha: Schema.String })), - head: Schema.optional(Schema.Struct({ sha: Schema.String })), - }), - ), -); - -const decodeMergeMessageResponse = Schema.decodeUnknownResult( - Schema.fromJsonString( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - pullRequest: Schema.Struct({ - isMergeQueueEnabled: Schema.Boolean, - headRefOid: Schema.String, - viewerMergeBodyText: Schema.String, - }), - }), - }), - }), - ), -); -const decodeMergeMessage = (raw: string) => - Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); - -/** What `gh pr merge` and `gh pr update-branch` read before they act. */ -const ACTION_STATE_GRAPHQL_QUERY = ` -query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - id - headRefOid - isMergeQueueEnabled - mergeStateStatus - baseRef { compare(headRef: $headRef) { behindBy } } - } - } -}`; - -const decodeActionStateResponse = Schema.decodeUnknownResult( - Schema.fromJsonString( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.Struct({ - pullRequest: Schema.Struct({ - id: Schema.String, - headRefOid: Schema.String, - isMergeQueueEnabled: Schema.optional(Schema.Boolean), - mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), - baseRef: Schema.optional( - Schema.NullOr( - Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }), - ), - ), - }), - }), - }), - }), - ), -); -const decodeActionState = (raw: string) => - Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest); - -const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) { - mergePullRequest(input: $input) { clientMutationId } -}`; -const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) { - enablePullRequestAutoMerge(input: $input) { clientMutationId } -}`; -const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { - disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId } -}`; -const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) { - updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId } -}`; -const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { - markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId } -}`; -const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { - convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId } -}`; -const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { - closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } -}`; -const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { - reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } -}`; -const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) { - addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId } -}`; - -const GRAPHQL_MERGE_METHODS = { - merge: "MERGE", - squash: "SQUASH", - rebase: "REBASE", -} as const satisfies Record; - -/** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */ -const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); - -/** The mutations that only need the pull request's node id. */ -const SIMPLE_ACTION_MUTATIONS = { - "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION, - ready: READY_FOR_REVIEW_GRAPHQL_MUTATION, - draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION, - close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION, - reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION, -} as const satisfies Partial>; - -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const api = yield* GitHubApi.GitHubApi; - const sourceControlHost = yield* SourceControlHost.SourceControlHost; - const fileSystem = yield* FileSystem.FileSystem; - const revalidateChecks = yield* makeChecksRevalidator; - const routingIdentities = new Map< - string, - { - at: number; - value: { accountId: string; viewer: string }; - } - >(); - const identityLocks = new Map(); - const decodeRoutingIdentity = Schema.decodeUnknownEffect( - Schema.fromJsonString( - Schema.Struct({ - id: PositiveInt, - login: TrimmedNonEmptyString, - }), + const live = yield* cacheInOwnerScope( + Effect.promise(() => import("./GitHubPullRequestApiLive.ts")).pipe( + Effect.flatMap((module) => module.make), ), ); - const captureVerifiedCredential = Effect.fn("GitHubPullRequestApi.captureVerifiedCredential")( - function* (input: { readonly cwd: string; readonly host: string }) { - const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); - const host = input.host.toLowerCase(); - // A missing or signed-out credential keeps its own error, so the page can say which. - const { token, fingerprint: key } = yield* api.credential(host); - const credential = { host, token, credentialFingerprint: key }; - // A cold page may ask several times. Wait per credential and check again after the - // first verification; cancellation releases the next waiter without losing its request. - return yield* Effect.acquireUseRelease( - Effect.sync(() => { - const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 }; - lock.users++; - identityLocks.set(key, lock); - return lock; - }), - (lock) => - lock.gate.withPermit( - Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - const cached = routingIdentities.get(key); - if (cached !== undefined && now - cached.at < 10 * 60_000) - return { ...credential, ...cached.value }; - // Pin this read so an auth switch cannot poison its cache entry. - const response = yield* api - .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) - .pipe( - Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), - Effect.provideService(SourceControlRateLimit.CredentialScope, key), - ); - const identity = yield* decodeRoutingIdentity(response.body).pipe( - Effect.mapError(unavailable), - ); - const value = { accountId: String(identity.id), viewer: identity.login }; - if (routingIdentities.size >= 128) - routingIdentities.delete(routingIdentities.keys().next().value!); - routingIdentities.set(key, { at: now, value }); - return { ...credential, ...value }; - }), - ), - (lock) => - Effect.sync(() => { - lock.users--; - if (lock.users === 0) identityLocks.delete(key); - }), - ); - }, - ); - const withVerifiedCredential: GitHubPullRequestApi["Service"]["withVerifiedCredential"] = ( - input, - use, - ) => - captureVerifiedCredential(input).pipe( - Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => - use({ accountId, viewer, credentialFingerprint }).pipe( - Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), - Effect.provideService(GitHubApi.PinnedGitHubCredential, { - host, - token, - credentialFingerprint, - }), - ), - ), - ); - const getRoutingIdentity: GitHubPullRequestApi["Service"]["getRoutingIdentity"] = (input) => - captureVerifiedCredential(input).pipe( - Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), - ); - - /** - * The pull request's own node id, which is what a mutation against the pull request itself is - * addressed by: a reaction on its description, or a rewrite of its words. - * - * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader - * ticking files viewed would otherwise pay a GraphQL round trip per press. - */ - const nodeIds = new Map(); - - const pullRequestNodeId = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly operation: string; - }): Effect.Effect => { - const { owner, name } = parseRepositorySelector(input.repository); - const key = `${input.host} ${owner}/${name} ${input.number}`; - const held = nodeIds.get(key); - if (held !== undefined) { - // Put back at the end on every hit, so what falls out is the pull request nobody has looked - // at rather than the one being ticked through: a run of cold reads would otherwise evict the - // open review and make it pay a round trip per press. - nodeIds.delete(key); - nodeIds.set(key, held); - return Effect.succeed(held); - } - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: input.operation, - allowReserve: true, - variables: { owner, name, number: input.number }, - query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, - decode: decodePullRequestNodeIdJson, - }).pipe( - Effect.tap((nodeId) => - Effect.sync(() => { - if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) { - const oldest = nodeIds.keys().next().value; - if (oldest !== undefined) nodeIds.delete(oldest); - } - nodeIds.set(key, nodeId); - }), - ), - ); - }; - - /** - * Whether a client-given subject actually belongs to the pull request the request names. A - * subject id is trusted to be whatever node it names, and that node can hang off any pull - * request on the host — so the mutation itself would write wherever the id actually belongs, - * not wherever the request says it does, unless this confirms the two agree first. - */ - const subjectBelongsToPullRequest = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly subjectId: string; - readonly operation: string; - }) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: input.operation, - allowReserve: true, - variables: { owner, name, number: input.number, subjectId: input.subjectId }, - query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, - decode: decodeReactionSubjectScopeJson, - }); - }; - - /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */ - const graphql = (input: { - readonly host: string; - readonly operation: string; - readonly query: string; - readonly variables: Readonly>; - }) => api.graphql(input).pipe(Effect.asVoid); - - const readError = (cwd: string, operation: string, cause: unknown) => - new GitHubPullRequestReadError({ cwd, operation, cause }); - - /** Decodes an answer, reporting a failure against the read that made it. */ - const decodeWith = ( - cwd: string, - operation: string, - decode: (raw: string) => Result.Result, - raw: string, - ): Effect.Effect => { - const decoded = decode(raw.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail(readError(cwd, operation, decoded.failure)); - }; - - /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ - const graphqlRead = (input: { - readonly cwd: string; - readonly host: string; - readonly operation: string; - readonly allowReserve?: boolean | undefined; - readonly variables?: Readonly>; - readonly query: string; - readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => - api - .graphql({ - host: input.host, - operation: input.operation, - query: input.query, - ...(input.variables === undefined ? {} : { variables: input.variables }), - ...(input.allowReserve === true ? { allowReserve: true } : {}), - }) - .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw))); - - /** A REST read whose JSON answer is decoded the same way. */ - const restRead = (input: { - readonly cwd: string; - readonly host: string; - readonly operation: string; - readonly path: string; - readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => - api - .rest({ host: input.host, operation: input.operation, path: input.path }) - .pipe( - Effect.flatMap((response) => - decodeWith(input.cwd, input.operation, input.decode, response.body), - ), - ); - - /** - * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed - * files, and still serves those files' hunks here. - * - * A page is a whole number of files, so each one parses on its own; the caller carries on from - * `nextCursor` for as long as GitHub keeps handing pages back. - * - * A named commit is read from the commit endpoint, which lists the same file entries and pages - * them the same way, only wrapped in an object. - */ - const diffFilesPage = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - readonly page: number; - readonly commit?: string | undefined; - }): Effect.Effect => { - const { owner, name } = parseRepositorySelector(input.repository); - const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; - return api - .rest({ - host: input.host, - operation: "getPullRequestDiff", - path: - input.commit === undefined - ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` - : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, - maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, - timeout: DIFF_TIMEOUT, - }) - .pipe( - Effect.flatMap((response) => { - // Checked before decoding: a byte-truncated response is a JSON prefix, which would - // fail to parse. Nothing of this page can be shown, and an empty patch would render - // as a change with no files rather than as the failure it is; slices already handed - // over stay with the reader either way. - if (response.truncated) { - return Effect.fail( - readError( - input.cwd, - "getPullRequestDiff", - new Error(`Page ${input.page} of the changed files was too large to read.`), - ), - ); - } - return decodeWith( - input.cwd, - "getPullRequestDiff", - input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson, - response.body, - ); - }), - Effect.map((files) => { - // Counted before decoding, so a page whose files all failed to decode still moves on - // rather than pointing the reader back at the page it just read. - const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; - return { - patch: files.patch, - truncated: files.truncated, - nextCursor: morePages ? String(input.page + 1) : null, - ...(files.omittedFileStats.length === 0 - ? {} - : { omittedFileStats: files.omittedFileStats }), - }; - }), - ); - }; - - const getPullRequestDiffFileContents: GitHubPullRequestApi["Service"]["getPullRequestDiffFileContents"] = - (input) => - Effect.gen(function* () { - if (input.commit !== undefined && !isCommitSha(input.commit)) { - return yield* new GitHubDiffCommitError({ cwd: input.cwd }); - } - const { owner, name } = parseRepositorySelector(input.repository); - const refsResponse = yield* api.rest({ - host: input.host, - operation: "getPullRequestDiffFileContents", - path: - input.commit === undefined - ? `repos/${owner}/${name}/pulls/${input.number}` - : `repos/${owner}/${name}/commits/${input.commit}`, - timeout: DIFF_TIMEOUT, - }); - const refs = decodeRevisionRefs(refsResponse.body); - // A root commit has no parent, which is an absent old revision. Every file in it is new, - // so that is a usable answer whenever the caller does not need the old side. - const baseRef = Option.isSome(refs) - ? input.commit === undefined - ? refs.value.base?.sha - : (refs.value.parents?.[0]?.sha ?? "") - : undefined; - const headRef = Option.isSome(refs) - ? input.commit === undefined - ? refs.value.head?.sha - : refs.value.sha - : undefined; - const rootCommitNewFile = - input.commit !== undefined && input.changeType === "new" && baseRef === ""; - if ( - headRef === undefined || - (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || - !isCommitSha(headRef) - ) { - return yield* new GitHubDiffRevisionsUnavailableError({ - cwd: input.cwd, - number: input.number, - ...(input.commit === undefined ? {} : { commit: input.commit }), - }); - } - - const readFile = (revision: string, filePath: string) => - api - .rest({ - host: input.host, - operation: "getPullRequestDiffFileContents", - accept: "application/vnd.github.raw+json", - path: `repos/${owner}/${name}/contents/${filePath - .split("/") - .map(encodeURIComponent) - .join("/")}?ref=${encodeURIComponent(revision)}`, - maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, - timeout: DIFF_TIMEOUT, - }) - .pipe( - Effect.flatMap((response) => - response.truncated || response.body.includes("\0") || response.invalidUtf8 - ? Effect.fail( - new GitHubDiffFileContentsUnavailableError({ - cwd: input.cwd, - path: filePath, - reason: response.truncated ? "oversized" : "binary", - }), - ) - : Effect.succeed(response.body), - ), - ); - - const [oldContents, newContents] = yield* Effect.all( - [ - input.changeType === "new" - ? Effect.succeed("") - : readFile(baseRef ?? "", input.oldPath), - input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), - ], - { concurrency: 2 }, - ); - return { oldContents, newContents }; - }); - - /** - * Every check context of the head commit, a page at a time, for a rollup the detail read cut at - * its first hundred. Each page names the head it read, so a push mid-walk fails rather than - * mixing two revisions' checks. - */ - type CheckContextsPage = Result.Result.Success< - ReturnType - >; - const readAllCheckContexts = ( - input: Parameters[0], - allowReserve: boolean, - ) => - Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - const { pages, truncated } = yield* readGraphQlPages( - (after, previous: ReadonlyArray) => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestDetail", - allowReserve, - variables: { owner, name, number: input.number, after }, - query: pullRequestCheckContextsGraphQlQuery(input.host), - decode: decodePullRequestCheckContextsJson, - }).pipe( - // Each page names the head it read, so a push mid-walk fails rather than mixing - // two revisions' checks. - Effect.filterOrFail( - (page) => previous.length === 0 || page.headSha === previous[0]!.headSha, - () => - readError( - input.cwd, - "getPullRequestDetail", - new Error("Pull request head changed while reading checks."), - ), - ), - ), - { maxPages: CHECK_CONTEXT_PAGES, nextCursor: (page) => page.nextCursor }, - ); - return { - headSha: pages[0]?.headSha ?? null, - contexts: pages.flatMap((page) => page.contexts), - truncated, - }; - }); - - const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return SourceControlRateLimit.Interactive.pipe( - Effect.flatMap((allowReserve) => - graphqlRead({ - allowReserve, - cwd: input.cwd, - host: input.host, - operation: "getPullRequestDetail", - variables: { - owner, - name, - number: input.number, - headRef: `refs/pull/${input.number}/head`, - }, - query: pullRequestCoreGraphQlQuery(input.host), - decode: decodePullRequestCoreJson, - }).pipe( - // Past a hundred checks the first page would let the rest imply success, so the whole - // rollup is walked instead. - Effect.filterOrElse( - (core) => !core.checksTruncated, - (core) => - readAllCheckContexts(input, allowReserve).pipe( - Effect.filterOrFail( - (all) => all.headSha === core.headSha, - () => - readError( - input.cwd, - "getPullRequestDetail", - new Error("Pull request head changed while reading checks."), - ), - ), - Effect.map((all) => ({ - ...core, - ...pullRequestChecksFromContexts(all.contexts), - checksTruncated: all.truncated, - })), - ), - ), - ), - ), - ); - }; - - const workflowApprovalLimit = 1_000; - const workflowApprovalReadError = (cwd: string, cause: unknown) => - readError(cwd, "listWorkflowRunsRequiringApproval", cause); - - /** - * Every open pull request whose head branch carries this name, up to one past the limit. - * `truncated` says some were never read, which is never enough to call a head unique. - */ - const listHeadsByBranch = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly headBranch: string; - }) => - Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - const countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray }>) => - pages.reduce((total, page) => total + page.heads.length, 0); - const { pages, truncated } = yield* readGraphQlPages( - (after) => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listWorkflowRunsRequiringApproval", - allowReserve: true, - variables: { owner, name, head: input.headBranch, after }, - query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, - decode: decodePullRequestHeadsJson, - }), - { - nextCursor: (page) => page.nextCursor, - until: (pages) => countHeads(pages) > workflowApprovalLimit, - }, - ); - return { heads: pages.flatMap((page) => page.heads), truncated }; - }); - - /** - * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is - * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did. - */ - const listActionRequiredRuns = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly headSha: string; - readonly headBranch: string; - }) => - Effect.gen(function* () { - // The checks revalidator may have just confirmed every run of this head as current. - const known = yield* KnownWorkflowRuns; - if (known !== null && known.headSha === input.headSha) { - return known.runs.flatMap((run) => - // A run waiting on a maintainer reports `completed` with an `action_required` - // conclusion; the `status=action_required` query matches either. - (run.conclusion === "action_required" || run.status === "action_required") && - run.head_branch === input.headBranch - ? [ - { - id: run.id, - name: run.name?.trim() || `Workflow run ${run.id}`, - url: run.html_url?.trim() || null, - }, - ] - : [], - ); - } - const { owner, name } = parseRepositorySelector(input.repository); - const runs: GitHubWorkflowRunApproval[] = []; - for (let page = 1; runs.length <= workflowApprovalLimit; page++) { - const read: GitHubWorkflowRunPage = yield* restRead({ - cwd: input.cwd, - host: input.host, - operation: "listWorkflowRunsRequiringApproval", - path: `repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(input.headSha)}&branch=${encodeURIComponent(input.headBranch)}&event=pull_request&status=action_required&per_page=100&page=${page}`, - decode: decodeWorkflowRunsJson, - }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error))); - runs.push(...read.runs); - if (read.rawCount < 100) break; - } - return runs; - }); - - const listWorkflowRunsRequiringApproval: GitHubPullRequestApi["Service"]["listWorkflowRunsRequiringApproval"] = - (input) => - Effect.all( - [ - listHeadsByBranch(input).pipe( - Effect.flatMap( - ({ - heads, - truncated, - }): Effect.Effect< - GitHubPullRequestHead, - GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError - > => { - const exactHeads = heads.filter( - (pullRequest) => - pullRequest.headSha === input.headSha && - pullRequest.isCrossRepository === true && - pullRequest.headRepositoryOwner?.toLowerCase() === - input.headRepositoryOwner.toLowerCase(), - ); - if (truncated || heads.length > workflowApprovalLimit) { - return Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - cwd: input.cwd, - number: input.number, - reason: "head-list-truncated", - observedCount: heads.length, - limit: workflowApprovalLimit, - }), - ); - } - if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { - return Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - cwd: input.cwd, - number: input.number, - reason: "head-not-unique", - observedCount: exactHeads.length, - limit: workflowApprovalLimit, - }), - ); - } - return Effect.succeed(exactHeads[0]); - }, - ), - ), - listActionRequiredRuns(input).pipe( - Effect.flatMap((runs) => - runs.length > workflowApprovalLimit - ? Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - cwd: input.cwd, - number: input.number, - reason: "run-list-truncated", - observedCount: runs.length, - limit: workflowApprovalLimit, - }), - ) - : Effect.succeed(runs), - ), - ), - ], - { concurrency: 2 }, - ).pipe(Effect.map(([, runs]) => runs)); - - /** One pull request's summary, through the same aliased query the batch uses. */ - const viewPullRequestSummary = (input: PullRequestSummaryRead) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestSummary", - variables: { owner, name, number: input.number }, - query: pullRequestSummaryGraphQlQuery(input.host === "github.com"), - decode: decodePullRequestSummariesJson, - }).pipe( - Effect.flatMap((summaries) => { - const summary = summaries.get(0); - return summary === undefined - ? Effect.fail( - readError( - input.cwd, - "getPullRequestSummary", - new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`), - ), - ) - : Effect.succeed(summary); - }), - ); - }; - - /** - * Summaries asked for together, on one host under one credential, share aliased GraphQL reads - * of twenty-five: the background sync reads every linked pull request each minute, and one - * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector - * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. - */ - const summaryResolver = RequestResolver.makeGrouped({ - key: ({ request, context }) => - JSON.stringify([ - request.host.toLowerCase(), - Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) - ?.credentialFingerprint ?? null, - Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ]), - resolver: (entries) => { - const [first] = entries; - const batchable = entries.filter( - (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, - ); - // Stack membership rides along where GitHub serves stacks, so the background sync can skip - // the REST stack read for pull requests that are in none. - const document = buildPullRequestSummariesGraphQlQuery( - batchable.map((entry) => entry.request), - first.request.host === "github.com", - ); - const batched = - document === null - ? Effect.succeed(new Map()) - : graphqlRead({ - cwd: first.request.cwd, - host: first.request.host, - operation: "getPullRequestSummary", - ...document, - decode: decodePullRequestSummariesJson, - }); - return batched.pipe( - // A GraphQL error anywhere fails the whole document — one repository gone or out of - // reach — so a batch that could not be read leaves every entry to its own read. A paused - // budget is the exception: reading one at a time would only spend what is being saved. - Effect.catchCauseIf( - (cause) => - !Cause.hasInterruptsOnly(cause) && - !Cause.findErrorOption(cause).pipe( - Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), - ), - (cause) => - Effect.logDebug("batched pull request summary read failed", { cause }).pipe( - Effect.as(new Map()), - ), - ), - Effect.flatMap((summaries) => { - const unanswered = entries.filter((entry) => { - const summary = summaries.get(batchable.indexOf(entry)); - if (summary === undefined) return true; - entry.completeUnsafe(Exit.succeed(summary)); - return false; - }); - return Effect.forEach( - unanswered, - (entry) => - viewPullRequestSummary(entry.request).pipe( - Effect.exit, - Effect.map((exit) => entry.completeUnsafe(exit)), - ), - { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, - ); - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); - }), - ), - ); - }, - }).pipe( - RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), - RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), - ); - const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = (input) => - Effect.request(new PullRequestSummaryRead(input), summaryResolver); - - // Every watched pull request on a host in one read per pass. A pull request the batch has no - // answer for gets null, and its watch reads it in full; a failed batch fails every entry. - const watchFingerprintResolver = RequestResolver.makeGrouped< - PullRequestWatchFingerprintRead, - string - >({ - key: ({ request, context }) => - JSON.stringify([ - request.host.toLowerCase(), - Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) - ?.credentialFingerprint ?? null, - Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ]), - resolver: (entries) => { - const [first] = entries; - const batchable = entries.filter( - (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, - ); - const document = buildPullRequestWatchFingerprintsGraphQlQuery( - batchable.map((entry) => entry.request), - ); - const read = - document === null - ? Effect.succeed(new Map()) - : graphqlRead({ - cwd: first.request.cwd, - host: first.request.host, - operation: "getPullRequestWatchFingerprint", - ...document, - decode: decodePullRequestWatchFingerprintsJson, - }); - return read.pipe( - Effect.map((fingerprints) => { - for (const entry of entries) { - const index = batchable.indexOf(entry); - entry.completeUnsafe( - Exit.succeed(index === -1 ? null : (fingerprints.get(index) ?? null)), - ); - } - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); - }), - ), - ); - }, - }).pipe( - RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), - RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), - ); - const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] = - (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver); - return GitHubPullRequestApi.of({ - withVerifiedCredential, - revalidateChecks, - getRoutingIdentity, - getViewerLogin: (input) => - getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)), - - listPullRequests: (input) => { - const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); - const { owner, name } = parseRepositorySelector(input.repository); - const query = searchQuery({ ...input, repositories: [input.repository] }); - if (query === null) { - return Effect.fail( - new GitHubRepositorySelectorError({ - cwd: input.cwd, - operation: "listPullRequests", - }), - ); - } - /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */ - const collect = ( - rows: number, - page: ( - after: string | null, - rows: number, - ) => Effect.Effect, - ) => - Effect.gen(function* () { - const countRows = (pages: ReadonlyArray) => - pages.reduce((total, read) => total + read.rawCount, 0); - const { pages, truncated } = yield* readGraphQlPages( - (after, pages: ReadonlyArray) => - page(after, rows - countRows(pages)), - { - nextCursor: (read) => read.endCursor, - until: (pages) => countRows(pages) >= rows, - }, - ); - // `truncated` also covers a cursor GitHub repeated before `rows` arrived. - return { - items: pages.flatMap((read) => read.items), - rawCount: countRows(pages), - truncated, - }; - }); - const read = ( - continues: boolean, - requestedRows = input.limit + 1, - ): Effect.Effect => - collect(requestedRows, (after, rows) => - continues - ? graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listPullRequests", - // The reader's own words are in the query. - variables: { q: query, after }, - query: pullRequestSearchGraphQlQuery(rows, false, true), - decode: decodePullRequestSearchJson, - }) - : graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listPullRequests", - variables: { owner, name, states: LIST_STATES[input.state], after }, - query: pullRequestListGraphQlQuery(rows), - decode: decodePullRequestListJson, - }), - ).pipe( - Effect.flatMap(({ items: rawItems, rawCount, truncated: cutShort }) => { - const items = continues - ? rawItems - : rawItems.filter((item) => matchesUnsortedListing(item, input)); - if ( - !continues && - items.length < input.limit && - rawCount >= requestedRows && - requestedRows < fallbackMaxRows - ) { - const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); - if (nextRows > requestedRows) return read(false, nextRows); - } - return Effect.succeed({ - items: items.slice(0, input.limit), - // One row over the page size is the probe for a next page, and it is - // counted before decoding: a skipped malformed row must not end paging. - truncated: - cutShort || - (continues - ? rawCount > input.limit - : items.length > input.limit || rawCount >= requestedRows), - continues, - }); - }), - ); - // GitHub does not index every repository for search, and one it will not search answers - // with no rows rather than with an error — so an empty listing is read again from the - // repository's own list. Those rows come back newest-created first, an order no `updated:` - // qualifier can carry on from, so that page says it cannot be continued and the reader - // reaches the rest of it by asking for a larger page, as every listing used to. - // - // Only ever the first slice: a repository that answered the search once will answer it - // again, so an empty slice under a cursor is a repository that has run out. - // A text search that finds nothing has found nothing: falling back would answer it with the - // repository's whole list, which is every row the reader did not search for. The fallback - // is for a repository the index does not cover, and a listing with no text to match is the - // only place an empty answer can mean that. - // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just - // as well as search judges them over its own, so carrying them into the fallback answers - // the same read rather than a wider one. Free text is the one thing the fallback cannot - // judge locally — it lists rows, it does not search their text — so a query still rules - // the fallback out: an empty answer under one is already the answer. - const hasQuery = (input.query?.trim().length ?? 0) > 0; - return read(true).pipe( - Effect.filterOrElse( - (batch) => batch.items.length > 0 || input.cursor !== undefined || hasQuery, - () => read(false), - ), - Effect.flatMap((batch) => { - // Match the search query's host support, and enrich only rows that survived paging. - if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); - const chunks: Array> = []; - for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { - chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); - } - return Effect.forEach( - chunks, - (chunk) => { - const document = buildPullRequestStackMembershipsGraphQlQuery( - input.repository, - chunk.map((item) => item.number), - ); - if (document === null) return Effect.succeed(chunk); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listPullRequestStackMemberships", - ...document, - decode: decodePullRequestStackMembershipsJson, - }).pipe( - Effect.map((memberships) => - chunk.map((item, index) => { - const stack = memberships.get(index); - return stack === undefined ? item : { ...item, stack }; - }), - ), - // Optional badges must not take down a listing that already read successfully. - Effect.catch(() => - Effect.logWarning("Pull request stack membership enrichment failed", { - operation: "listPullRequestStackMemberships", - host: input.host, - rows: chunk.length, - }).pipe(Effect.as(chunk)), - ), - ); - }, - { concurrency: STAT_REQUEST_CONCURRENCY }, - ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); - }), - ); - }, - - searchPullRequests: (input) => { - const query = searchQuery(input); - if (query === null) { - return Effect.fail( - new GitHubRepositorySelectorError({ - cwd: input.cwd, - operation: "searchPullRequests", - }), - ); - } - // One extra row reveals that the host has more than the slice shows, the way the - // per-repository read does — up to GitHub's own ceiling on a search page, past which - // `hasNextPage` is what says there is more. - const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "searchPullRequests", - variables: { q: query, after: null }, - query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), - decode: decodePullRequestSearchJson, - }).pipe( - Effect.map((batch) => ({ - items: batch.items.slice(0, input.limit), - truncated: batch.rawCount > input.limit || batch.hasNextPage, - })), - ); - }, - - listPullRequestStats: (input) => { - const chunks: Array> = - []; - for (let start = 0; start < input.changeRequests.length; start += STAT_ALIASES_PER_REQUEST) { - chunks.push(input.changeRequests.slice(start, start + STAT_ALIASES_PER_REQUEST)); - } - return Effect.forEach( - chunks, - (chunk) => { - const document = buildPullRequestStatsGraphQlQuery(chunk); - if (document === null) { - return Effect.fail( - new GitHubRepositorySelectorError({ - cwd: input.cwd, - operation: "listPullRequestStats", - }), - ); - } - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listPullRequestStats", - ...document, - decode: decodePullRequestStatsJson, - }).pipe( - Effect.map((stats) => - chunk.flatMap((changeRequest, index) => { - const stat = stats.get(index); - return stat === undefined ? [] : [{ ...changeRequest, ...stat }]; - }), - ), - ); - }, - { concurrency: STAT_REQUEST_CONCURRENCY }, - ).pipe(Effect.map((results) => results.flat())); - }, - - getPullRequestSummary, - getPullRequestWatchFingerprint, - - getPullRequestDetail, - getPullRequestPreview: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestPreview", - variables: { owner, name, number: input.number }, - query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, - decode: decodePullRequestPreviewJson, - }); - }, - listWorkflowRunsRequiringApproval, - - getPullRequestStack: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return restRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestStack", - path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`, - decode: decodePullRequestStacksJson, - }).pipe( - // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. - Effect.flatMap((stack) => { - if (!input.includeDetails || stack === null) return Effect.succeed(stack); - return restRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestStack", - path: `repos/${owner}/${name}/stacks/${stack.number}`, - decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), - }); - }), - // Hosts without the stacks preview return 404. Other failures must preserve the - // previously synced stack and let the caller retry. - Effect.catchTags({ - GitHubApiNotFoundError: () => Effect.succeed(null), - }), - ); - }, - + withVerifiedCredential: (input, use) => + live.pipe(Effect.flatMap((value) => value.withVerifiedCredential(input, use))), + getRoutingIdentity: (input) => + live.pipe(Effect.flatMap((value) => value.getRoutingIdentity(input))), + getViewerLogin: (input) => live.pipe(Effect.flatMap((value) => value.getViewerLogin(input))), + listPullRequests: (input) => + live.pipe(Effect.flatMap((value) => value.listPullRequests(input))), + searchPullRequests: (input) => + live.pipe(Effect.flatMap((value) => value.searchPullRequests(input))), + listPullRequestStats: (input) => + live.pipe(Effect.flatMap((value) => value.listPullRequestStats(input))), + getPullRequestSummary: (input) => + live.pipe(Effect.flatMap((value) => value.getPullRequestSummary(input))), + getPullRequestWatchFingerprint: (input) => + live.pipe(Effect.flatMap((value) => value.getPullRequestWatchFingerprint(input))), + revalidateChecks: (input, read) => + live.pipe(Effect.flatMap((value) => value.revalidateChecks(input, read))), + getPullRequestDetail: (input) => + live.pipe(Effect.flatMap((value) => value.getPullRequestDetail(input))), + getPullRequestPreview: (input) => + live.pipe(Effect.flatMap((value) => value.getPullRequestPreview(input))), + listWorkflowRunsRequiringApproval: (input) => + live.pipe(Effect.flatMap((value) => value.listWorkflowRunsRequiringApproval(input))), + getPullRequestStack: (input) => + live.pipe(Effect.flatMap((value) => value.getPullRequestStack(input))), getPullRequestActivity: (input) => - Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - let author: PullRequestActor | null = null; - let commits: ReadonlyArray = []; - const remarks: PullRequestComment[] = []; - let commentsAfter: string | null = null; - let reviewsAfter: string | null = null; - let withComments = true; - let withReviews = true; - // Both remark lists page on their own; a page asks only for the ones with more to give. - for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { - const read: GitHubPullRequestActivityPage = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestActivity", - variables: { - owner, - name, - number: input.number, - head: page === 0, - withComments, - commentsAfter, - withReviews, - reviewsAfter, - }, - query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, - decode: decodePullRequestActivityJson, - }); - if (page === 0) { - author = read.author ?? null; - commits = read.commits ?? []; - } - remarks.push(...read.remarks); - commentsAfter = read.nextCommentsCursor; - reviewsAfter = read.nextReviewsCursor; - withComments = withComments && commentsAfter !== null; - withReviews = withReviews && reviewsAfter !== null; - } - return { - author, - comments: remarks.toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ), - commits, - } satisfies GitHubPullRequestActivity; - }), - - getPullRequestDiff: (input) => { - const filesPage = (page: number) => - diffFilesPage({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - number: input.number, - page, - ...(input.commit === undefined ? {} : { commit: input.commit }), - }); - if (input.commit !== undefined && !isCommitSha(input.commit)) { - return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd })); - } - // A cursor only ever comes from the files walk, so a reader carrying one is already past - // the point where `gh pr diff` had anything to say. - if (input.cursor !== undefined) { - const page = diffCursorPage(input.cursor); - return page === null - ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd })) - : filesPage(page); - } - // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. - if (input.commit !== undefined) { - return filesPage(1); - } - const { owner, name } = parseRepositorySelector(input.repository); - return api - .rest({ - host: input.host, - operation: "getPullRequestDiff", - path: `repos/${owner}/${name}/pulls/${input.number}`, - accept: "application/vnd.github.diff", - maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, - timeout: DIFF_TIMEOUT, - }) - .pipe( - Effect.flatMap((response) => - // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor - // something the reader can carry on from. The files API can serve the same change a - // whole number of files at a time, so an oversized patch takes that road as well. - response.truncated - ? filesPage(1) - : // One read served the whole patch, so there is no next slice to ask for. - Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), - ), - // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read - // from the files API instead, a page per call. Only once the direct read has failed: a - // pull request GitHub will serve a diff for must not pay for a second request. A - // fallback that fails too reports the original refusal, which is the one that explains - // the page. Narrowed to a request GitHub answered and refused: a missing credential or - // a rate limit fails the same way for every request. - Effect.catchTags({ - GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), - }), - ); - }, - - getPullRequestDiffFileContents, - - getReviewThreadComments: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getReviewThreadComments", - variables: { - owner, - name, - number: input.number, - threadId: input.threadId, - cursor: input.cursor, - }, - query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, - decode: decodeReviewThreadCommentsJson, - }).pipe( - Effect.flatMap(({ belongsToPullRequest, comments, nextCursor }) => - belongsToPullRequest - ? Effect.succeed({ comments, nextCursor }) - : Effect.fail( - new GitHubSubjectScopeError({ - cwd: input.cwd, - operation: "getReviewThreadComments", - }), - ), - ), - ); - }, - + live.pipe(Effect.flatMap((value) => value.getPullRequestActivity(input))), + getPullRequestDiff: (input) => + live.pipe(Effect.flatMap((value) => value.getPullRequestDiff(input))), + getPullRequestDiffFileContents: (input) => + live.pipe(Effect.flatMap((value) => value.getPullRequestDiffFileContents(input))), + getPullRequestFilesViewed: (input) => + live.pipe(Effect.flatMap((value) => value.getPullRequestFilesViewed(input))), + setPullRequestFilesViewed: (input) => + live.pipe(Effect.flatMap((value) => value.setPullRequestFilesViewed(input))), listReviewThreadComments: (input) => - Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - const threadPage = ( - cursor: string | null, - ): Effect.Effect => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listReviewThreadComments", - variables: { owner, name, number: input.number, cursor }, - query: REVIEW_THREADS_GRAPHQL_QUERY, - decode: decodeReviewThreadsJson, - }); - const entries: GitHubReviewThreadEntry[] = []; - const avatarsByLogin = new Map(); - const botLogins = new Set(); - const commitStats = new Map< - string, - { readonly additions: number; readonly deletions: number } - >(); - let reviewers: ReadonlyArray = []; - let reactions: GitHubReviewThreadPage["reactions"] = []; - const reactionsById = new Map>(); - const editedAtById = new Map(); - let commits: GitHubReviewThreadPage["commits"] = []; - let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; - const dismissalsByReviewId = new Map(); - let dismissalCursor: string | null = null; - let cursor: string | null = null; - let page = 0; - do { - const read: GitHubReviewThreadPage = yield* threadPage(cursor); - entries.push(...read.threads); - for (const login of read.botLogins) botLogins.add(login); - for (const [login, avatarUrl] of read.avatarsByLogin) - avatarsByLogin.set(login, avatarUrl); - // The roster, the commits and the viewer's standing travel with every page, and the - // first one already carries all of them. - if (page === 0) { - reviewers = read.reviewers; - reactions = read.reactions; - for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry); - for (const [id, editedAt] of read.editedAtById) editedAtById.set(id, editedAt); - commits = read.commits; - viewer = read.viewer; - for (const [id, message] of read.dismissalsByReviewId) - dismissalsByReviewId.set(id, message); - dismissalCursor = read.nextDismissalCursor; - for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); - } - cursor = read.nextCursor; - page += 1; - } while (cursor !== null && page < REVIEW_THREAD_PAGES); - - // Almost never entered: the embedded page already holds every dismissal a pull request - // ordinarily accrues. Followed so a review whose event fell past that page still finds - // its reason. - if (dismissalCursor !== null) { - const { pages } = yield* readGraphQlPages( - (cursor) => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listReviewThreadComments", - variables: { owner, name, number: input.number, cursor }, - query: REVIEW_DISMISSALS_GRAPHQL_QUERY, - decode: decodeReviewDismissalsJson, - }), - { - from: dismissalCursor, - maxPages: REVIEW_THREAD_PAGES, - nextCursor: (read) => read.nextCursor, - }, - ); - for (const read of pages) - for (const [id, message] of read.dismissalsByReviewId) - dismissalsByReviewId.set(id, message); - } - - const reviewThreads = entries.map((entry) => ({ - ...entry.thread, - commentCount: entry.commentCount, - ...(entry.nextCommentCursor === null - ? {} - : { nextCommentsCursor: entry.nextCommentCursor }), - })); - return { - comments: reviewThreadConversation(reviewThreads), - dismissalsByReviewId, - reviewThreads, - // GitHub's own count of each thread, so the number the page shows is the host's even - // where a bound kept some of the words on GitHub. - commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0), - truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null), - reviewThreadsTruncated: cursor !== null, - reactions, - reactionsById, - editedAtById, - reviewers, - avatarsByLogin, - botLogins, - commitStats, - commits, - viewer, - }; - }), - - listActorAvatars: (input) => { - if (input.ids.length === 0) { - return Effect.succeed(new Map()); - } - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listActorAvatars", - variables: { ids: input.ids }, - query: ACTOR_AVATARS_GRAPHQL_QUERY, - decode: decodeActorAvatarsJson, - }); - }, - - getViewerAccess: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getViewerAccess", - ...(input.allowReserve === true ? { allowReserve: true } : {}), - variables: { owner, name, number: input.number }, - query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, - decode: decodeViewerPermissionsJson, - }); - }, - - listReviewerCandidates: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listReviewerCandidates", - allowReserve: true, - variables: { owner, name, number: input.number }, - query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, - decode: decodeReviewerCandidatesJson, - }); - }, - - setReviewerRequest: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - // Posting to a login GitHub has already been asked about is what a re-request is, so - // there is nothing to say here about somebody who has reviewed once already. - return api - .rest({ - host: input.host, - operation: "setReviewerRequest", - method: input.requested ? "POST" : "DELETE", - path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, - body: buildReviewerRequest(input.reviewers), - }) - .pipe(Effect.asVoid); - }, - - listLabelCandidates: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listLabelCandidates", - allowReserve: true, - variables: { owner, name, number: input.number }, - query: LABEL_CANDIDATES_GRAPHQL_QUERY, - decode: decodeLabelCandidatesJson, - }); - }, - - setLabels: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - // A pull request is an issue to the labels API. Adding posts a list and leaves what was - // already there; taking off is one delete per label, since the endpoint names one in its - // path. The name goes into the path encoded, because a label may carry a space or a slash. - const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; - if (input.applied) { - return api - .rest({ - host: input.host, - operation: "setLabels", - method: "POST", - path: issue, - body: buildLabelRequest(input.labels), - }) - .pipe(Effect.asVoid); - } - return Effect.forEach( - input.labels, - (label) => - api.rest({ - host: input.host, - operation: "setLabels", - method: "DELETE", - path: `${issue}/${encodeURIComponent(label)}`, - }), - { concurrency: 1, discard: true }, - ); - }, - - runPullRequestAction: (input) => { - if (input.stackNumber !== undefined) - return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( - Effect.provideService(GitHubApi.GitHubApi, api), - Effect.provideService(SourceControlHost.SourceControlHost, sourceControlHost), - Effect.provideService(FileSystem.FileSystem, fileSystem), - ); - if (input.action === "revert") { - return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( - Effect.flatMap((pullRequestId) => - graphql({ - host: input.host, - operation: "revertPullRequest", - query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, - variables: { pullRequestId }, - }), - ), - ); - } - if (input.action === "approve-workflows") { - const { owner, name } = parseRepositorySelector(input.repository); - return getPullRequestDetail(input).pipe( - Effect.flatMap((detail) => { - if (detail.isCrossRepository !== true) return Effect.void; - if (detail.headSha == null || detail.headRepositoryOwner == null) { - return Effect.fail( - new GitHubWorkflowApprovalHeadUnavailableError({ - cwd: input.cwd, - number: input.number, - }), - ); - } - const expectedHeadSha = detail.headSha; - const expectedHeadBranch = detail.headBranch; - const expectedHeadRepositoryOwner = detail.headRepositoryOwner; - return listWorkflowRunsRequiringApproval({ - ...input, - headSha: expectedHeadSha, - headBranch: expectedHeadBranch, - headRepositoryOwner: expectedHeadRepositoryOwner, - isCrossRepository: true, - }).pipe( - Effect.flatMap((runs) => - Effect.forEach( - runs, - (run) => - getPullRequestDetail(input).pipe( - Effect.flatMap((current) => { - if (current.headSha == null || current.headRepositoryOwner == null) { - return Effect.fail( - new GitHubWorkflowApprovalHeadUnavailableError({ - cwd: input.cwd, - number: input.number, - }), - ); - } - if ( - current.isCrossRepository !== true || - current.headSha !== expectedHeadSha || - current.headBranch !== expectedHeadBranch || - current.headRepositoryOwner.toLowerCase() !== - expectedHeadRepositoryOwner.toLowerCase() - ) { - return Effect.fail( - new GitHubWorkflowApprovalHeadChangedError({ - cwd: input.cwd, - number: input.number, - }), - ); - } - return listWorkflowRunsRequiringApproval({ - ...input, - headSha: current.headSha, - headBranch: current.headBranch, - headRepositoryOwner: current.headRepositoryOwner, - isCrossRepository: true, - }); - }), - Effect.flatMap((currentRuns) => - currentRuns.some((current) => current.id === run.id) - ? api - .rest({ - host: input.host, - operation: "approveWorkflowRun", - method: "POST", - path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`, - }) - .pipe(Effect.asVoid) - : Effect.void, - ), - ), - { concurrency: 1, discard: true }, - ), - ), - ); - }), - ); - } - const action = input.action; - if (action in SIMPLE_ACTION_MUTATIONS) { - return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe( - Effect.flatMap((pullRequestId) => - graphql({ - host: input.host, - operation: "runPullRequestAction", - query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS], - variables: { pullRequestId }, - }), - ), - ); - } - return Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - // Read fresh rather than from the node id cache: merging and updating act on the head - // as it stands now, and the merge queue decides which mutation a merge is. - const state = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "runPullRequestAction", - allowReserve: true, - query: ACTION_STATE_GRAPHQL_QUERY, - variables: { - owner, - name, - number: input.number, - headRef: `refs/pull/${input.number}/head`, - }, - decode: decodeActionState, - }); - if (action === "update-branch") { - // Already current is done, the way `gh pr update-branch` reports it. - if (state.baseRef?.compare?.behindBy === 0) return; - // GitHub updates with a merge commit unless asked to rebase, which is its own default. - return yield* graphql({ - host: input.host, - operation: "runPullRequestAction", - query: UPDATE_BRANCH_GRAPHQL_MUTATION, - variables: { - pullRequestId: state.id, - expectedHeadOid: state.headRefOid, - updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE", - }, - }); - } - let body: string | undefined; - let expectedHead: string | undefined; - if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { - const message = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "runPullRequestAction", - allowReserve: true, - query: MERGE_MESSAGE_GRAPHQL_QUERY, - variables: { - owner, - name, - number: input.number, - method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE", - }, - decode: decodeMergeMessage, - }); - // GitHub's merge queue chooses its own message and ignores custom text. - if (!message.isMergeQueueEnabled) { - const cleaned = removeAgentCredits(message.viewerMergeBodyText); - if (cleaned !== message.viewerMergeBodyText) { - body = cleaned; - expectedHead = message.headRefOid; - } - } - } - // A merge queue takes a pull request through auto-merge rather than a direct merge, and - // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. - // GitHub stores the strategy with a standing instruction rather than choosing one at - // merge time, so arming still names it. - const auto = - state.isMergeQueueEnabled === true || - (action === "enable-auto-merge" && - !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); - yield* graphql({ - host: input.host, - operation: "runPullRequestAction", - query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION, - variables: { - input: { - pullRequestId: state.id, - mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"], - ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }), - ...(body === undefined ? {} : { commitBody: body }), - }, - }, - }); - }); - }, - + live.pipe(Effect.flatMap((value) => value.listReviewThreadComments(input))), + listActorAvatars: (input) => + live.pipe(Effect.flatMap((value) => value.listActorAvatars(input))), + getReviewThreadComments: (input) => + live.pipe(Effect.flatMap((value) => value.getReviewThreadComments(input))), + getViewerAccess: (input) => live.pipe(Effect.flatMap((value) => value.getViewerAccess(input))), + listReviewerCandidates: (input) => + live.pipe(Effect.flatMap((value) => value.listReviewerCandidates(input))), + setReviewerRequest: (input) => + live.pipe(Effect.flatMap((value) => value.setReviewerRequest(input))), + listLabelCandidates: (input) => + live.pipe(Effect.flatMap((value) => value.listLabelCandidates(input))), + setLabels: (input) => live.pipe(Effect.flatMap((value) => value.setLabels(input))), + runPullRequestAction: (input) => + live.pipe(Effect.flatMap((value) => value.runPullRequestAction(input))), commentOnPullRequest: (input) => - pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe( - Effect.flatMap((subjectId) => - graphql({ - host: input.host, - operation: "commentOnPullRequest", - query: ADD_COMMENT_GRAPHQL_MUTATION, - variables: { subjectId, body: input.body }, - }), - ), - ), - - submitReview: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - // The whole review is one request, so nothing is visible to anyone else until the verdict - // is sent. - return api - .rest({ - host: input.host, - operation: "submitReview", - method: "POST", - path: `repos/${owner}/${name}/pulls/${input.number}/reviews`, - body: buildReviewSubmission({ - verdict: input.verdict, - body: input.body, - comments: input.comments, - }), - }) - .pipe(Effect.asVoid); - }, - + live.pipe(Effect.flatMap((value) => value.commentOnPullRequest(input))), + submitReview: (input) => live.pipe(Effect.flatMap((value) => value.submitReview(input))), replyToReviewThread: (input) => - graphql({ - host: input.host, - operation: "replyToReviewThread", - query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, - variables: { threadId: input.threadId, body: input.body }, - }), - - getPullRequestFilesViewed: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return readGraphQlPages( - (after) => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestFilesViewed", - variables: { owner, name, number: input.number, after }, - query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, - decode: decodePullRequestFilesViewedJson, - }), - { maxPages: FILES_VIEWED_MAX_PAGES, nextCursor: (page) => page.nextCursor }, - ).pipe( - Effect.map(({ pages, truncated }) => ({ - files: pages.flatMap((page) => page.files), - truncated, - })), - ); - }, - - setPullRequestFilesViewed: (input) => { - if (input.files.length === 0) return Effect.void; - return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( - Effect.flatMap((pullRequestId) => { - const mutation = buildSetFilesViewedGraphQlMutation(pullRequestId, input.files); - return mutation === null - ? Effect.void - : graphql({ host: input.host, operation: "setPullRequestFilesViewed", ...mutation }); - }), - ); - }, - + live.pipe(Effect.flatMap((value) => value.replyToReviewThread(input))), setReviewThreadResolution: (input) => - graphql({ - host: input.host, - operation: "setReviewThreadResolution", - query: input.resolved - ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION - : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, - variables: { threadId: input.threadId }, - }), - - setReaction: (input) => { - const givenSubjectId = input.subjectId; - const subjectId = - givenSubjectId === undefined - ? pullRequestNodeId({ ...input, operation: "setReaction" }) - : subjectBelongsToPullRequest({ - ...input, - subjectId: givenSubjectId, - operation: "setReaction", - }).pipe( - Effect.flatMap((belongs) => - belongs - ? Effect.succeed(givenSubjectId) - : Effect.fail( - new GitHubSubjectScopeError({ - cwd: input.cwd, - operation: "setReaction", - }), - ), - ), - ); - return subjectId.pipe( - Effect.flatMap((subjectId) => - graphql({ - host: input.host, - operation: "setReaction", - query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, - variables: { subjectId, content: gitHubReactionContent(input.content) }, - }), - ), - ); - }, - + live.pipe(Effect.flatMap((value) => value.setReviewThreadResolution(input))), + setReaction: (input) => live.pipe(Effect.flatMap((value) => value.setReaction(input))), updatePullRequest: (input) => - pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( - Effect.flatMap((pullRequestId) => - graphql({ - host: input.host, - operation: "updatePullRequest", - query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, - // A field the caller did not name is left out of the request entirely, so GitHub - // keeps the words that are there rather than being asked for an empty one. - variables: { - pullRequestId, - ...(input.title === undefined ? {} : { title: input.title }), - ...(input.body === undefined ? {} : { body: input.body }), - }, - }), - ), - ), - - updateComment: (input) => - subjectBelongsToPullRequest({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - number: input.number, - subjectId: input.commentId, - operation: "updateComment", - }).pipe( - Effect.flatMap((belongs) => - belongs - ? Effect.succeed(input.commentId) - : Effect.fail( - new GitHubSubjectScopeError({ - cwd: input.cwd, - operation: "updateComment", - }), - ), - ), - Effect.flatMap((commentId) => - graphql({ - host: input.host, - operation: "updateComment", - query: - input.kind === "issue-comment" - ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION - : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, - variables: { commentId, body: input.body }, - }), - ), - ), + live.pipe(Effect.flatMap((value) => value.updatePullRequest(input))), + updateComment: (input) => live.pipe(Effect.flatMap((value) => value.updateComment(input))), }); }); - export const layer = Layer.effect(GitHubPullRequestApi, make); diff --git a/packages/source-control-github/src/server/GitHubPullRequestApiLive.ts b/packages/source-control-github/src/server/GitHubPullRequestApiLive.ts new file mode 100644 index 0000000000000..1e257071598f5 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubPullRequestApiLive.ts @@ -0,0 +1,2281 @@ +import { + GitHubPullRequestReadError, + GitHubViewerLoginUnavailableError, + GitHubDiffCursorError, + GitHubDiffCommitError, + GitHubDiffRevisionsUnavailableError, + GitHubDiffFileContentsUnavailableError, + GitHubRepositorySelectorError, + GitHubSubjectScopeError, + GitHubWorkflowApprovalRefusedError, + GitHubWorkflowApprovalHeadUnavailableError, + GitHubWorkflowApprovalHeadChangedError, + type GitHubPullRequestApiError, + NODE_ID_CACHE_CAPACITY, + type GitHubPullRequestListBatch, + type GitHubPullRequestDiffSlice, + GitHubPullRequestApi, +} from "./GitHubPullRequestApi.ts"; +import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; +import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; +import { runGitHubStackAction } from "./githubStackActions.ts"; +import * as Context from "effect/Context"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { + resolvePullRequestAuthorFilter, + PositiveInt, + TrimmedNonEmptyString, + type PullRequestAction, + type PullRequestActor, + type PullRequestComment, + type PullRequestCommit, + type PullRequestInvolvement, + type PullRequestListFilters, + type PullRequestListState, + type PullRequestMergeMethod, + type PullRequestReaction, +} from "@t3tools/contracts"; + +import * as GitHubApi from "./GitHubApi.ts"; +import { readGraphQlPages } from "./githubGraphQl.ts"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import { + ACTOR_AVATARS_GRAPHQL_QUERY, + ADD_REACTION_GRAPHQL_MUTATION, + buildReviewSubmission, + buildReviewerRequest, + buildSetFilesViewedGraphQlMutation, + decodeActorAvatarsJson, + decodePullRequestActivityJson, + decodePullRequestCheckContextsJson, + decodePullRequestCoreJson, + decodeCommitFilesJson, + pullRequestCheckContextsGraphQlQuery, + pullRequestChecksFromContexts, + pullRequestListGraphQlQuery, + pullRequestSummaryGraphQlQuery, + PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, + PULL_REQUEST_HEADS_GRAPHQL_QUERY, + decodeWorkflowRunsJson, + pullRequestCoreGraphQlQuery, + type GitHubPullRequestSummary, + type GitHubPullRequestWatchFingerprint, + decodePullRequestPreviewJson, + PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, + decodePullRequestFilesJson, + decodePullRequestFilesViewedJson, + decodePullRequestHeadsJson, + decodePullRequestListJson, + decodePullRequestNodeIdJson, + decodePullRequestSearchJson, + decodePullRequestStacksJson, + decodePullRequestStatsJson, + decodePullRequestSummariesJson, + decodePullRequestWatchFingerprintsJson, + decodeReactionSubjectScopeJson, + decodeReviewerCandidatesJson, + decodeLabelCandidatesJson, + buildLabelRequest, + LABEL_CANDIDATES_GRAPHQL_QUERY, + decodeReviewDismissalsJson, + decodeReviewThreadCommentsJson, + decodeReviewThreadsJson, + buildPullRequestStatsGraphQlQuery, + buildPullRequestSummariesGraphQlQuery, + buildPullRequestWatchFingerprintsGraphQlQuery, + buildPullRequestStackMembershipsGraphQlQuery, + decodePullRequestStackMembershipsJson, + pullRequestSearchGraphQlQuery, + PULL_REQUEST_SEARCH_MAX_ROWS, + PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, + PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, + REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, + REMOVE_REACTION_GRAPHQL_MUTATION, + REVERT_PULL_REQUEST_GRAPHQL_MUTATION, + gitHubReactionContent, + RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + REVIEWER_CANDIDATES_GRAPHQL_QUERY, + REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, + REVIEW_DISMISSALS_GRAPHQL_QUERY, + REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, + REVIEW_THREADS_GRAPHQL_QUERY, + reviewThreadConversation, + UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION, + UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, + UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, + VIEWER_PERMISSIONS_GRAPHQL_QUERY, + decodeViewerPermissionsJson, + type GitHubPullRequestActivity, + type GitHubPullRequestActivityPage, + type GitHubWorkflowRunPage, + type GitHubPullRequestHead, + type GitHubPullRequestListItem, + type GitHubWorkflowRunApproval, + type GitHubReviewThreadEntry, + type GitHubReviewThreadPage, +} from "./gitHubPullRequestJson.ts"; +import type { + ProviderChangeRequestSummary, + ProviderListCursor, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ +const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const DIFF_TIMEOUT = Duration.seconds(60); +/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ +const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; + +/** What `gh pr list --state` asked a repository's own list for, without search. */ +const LIST_STATES: Record> = { + all: ["OPEN", "CLOSED", "MERGED"], + open: ["OPEN"], + // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out + // locally by `matchesUnsortedListing`. + closed: ["CLOSED", "MERGED"], + merged: ["MERGED"], +}; + +interface GitHubPullRequestListPage { + readonly items: ReadonlyArray; + readonly rawCount: number; + readonly endCursor: string | null; +} + +/** A search-free fallback may scan older rows for local filters, but never the whole repository. */ +const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; + +/** What the files API serves at most in one response, which is what one slice is made of. */ +const DIFF_FILES_PAGE_SIZE = 100; +/** + * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL + * budget per page, against a change request nobody reviews in one sitting past the first few + * hundred files: beyond this the read stops and says it was cut short. + */ +const FILES_VIEWED_MAX_PAGES = 5; + +/** + * How many pull requests' node ids are remembered at once. A long-lived server sees far more of + * them than a reader ever has open, and least recently used rather than first in: a listing + * walking cold pull requests must not evict the review being ticked through. + */ + +/** + * Pages of review threads to follow before the conversation is reported as truncated. GitHub + * serves a hundred threads a page, so this is a thousand threads — past anything a pull request + * a person is reading has, and short of walking a repository-sized conversation forever. + */ +const REVIEW_THREAD_PAGES = 10; + +/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ +const CHECK_CONTEXT_PAGES = 10; + +/** + * Aliased lookups per request, and requests at once. Measured over a hundred rows: one request + * carrying all hundred takes ~5.2s, four of twenty-five in parallel ~2.1s. + */ +const STAT_ALIASES_PER_REQUEST = 25; +const STAT_REQUEST_CONCURRENCY = 4; +/** + * How long a summary read waits for company. The background sync asks for every linked pull + * request at once, and each read reaches the resolver after its own cache check, so a batch + * needs a moment longer than one scheduler tick to gather them. + */ +const SUMMARY_BATCH_WINDOW = "10 millis"; + +class PullRequestSummaryRead extends Request.Class< + { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }, + ProviderChangeRequestSummary, + GitHubPullRequestApiError +> {} + +class PullRequestWatchFingerprintRead extends Request.Class< + { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }, + GitHubPullRequestWatchFingerprint | null, + GitHubPullRequestApiError +> {} + +/** + * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. + * The host is not read off the identity: it travels alongside it, because the identity a + * project records is the path below its host and never names the host itself. + */ +function parseRepositorySelector(value: string): { + readonly owner: string; + readonly name: string; +} { + const parts = value.trim().split("/").filter(Boolean); + return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" }; +} + +/** + * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor + * arrives from the reader as a string and goes straight into a request path, so it is parsed + * rather than trusted; the length bound keeps a page number out of exponential notation. + */ +function diffCursorPage(cursor: string): number | null { + return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; +} + +/** + * A commit sha arrives from the reader and goes straight into a request path, so it is checked + * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a + * whole sha. + */ +function isCommitSha(value: string): boolean { + return /^[0-9a-f]{7,64}$/i.test(value); +} + +/** + * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole + * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so + * text typed into a search box could widen the very listing it is meant to narrow — inside them + * it is only text. The two characters that could end the phrase early are therefore escaped + * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which + * would let everything after it out of the phrase. + * + * The phrase is one argv element, so nothing in it can become a flag of its own either. + */ +function searchPhrase(query: string): string { + return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +/** GitHub's own spelling of a review state, which is not the contract's. */ +const REVIEW_QUALIFIERS = { + approved: "approved", + "changes-requested": "changes_requested", + "review-required": "required", + none: "none", +} as const; + +/** + * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the + * one character that could end the quoted value early is dropped rather than escaped: no GitHub + * label or login holds a double quote, so there is nothing to preserve and everything to lose. + */ +function qualifierValue(value: string): string { + return `"${value.replaceAll('"', "").trim()}"`; +} + +function filterQualifiers( + filters: PullRequestListFilters | undefined, + viewer: string, +): ReadonlyArray { + if (filters === undefined) return []; + return [ + // One qualifier per group, its names joined by commas — GitHub's own OR. + ...(filters.labels ?? []).flatMap((group) => + group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`], + ), + ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`), + ...(filters.author === undefined + ? [] + : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]), + ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]), + ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]), + ...(filters.checks === undefined + ? [] + : [`status:${filters.checks === "passing" ? "success" : "failure"}`]), + ]; +} + +/** + * The same narrowings over a row that has already arrived, for the search-free fallback. Every + * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by + * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an + * absent state on purpose — a row with no checks configured, or whose checks are still `pending`, + * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would + * not have surfaced for `status:success` or `status:failure`. + */ +function matchesFilters( + item: GitHubPullRequestListItem, + filters: PullRequestListFilters | undefined, + viewer: string, +): boolean { + if (filters === undefined) return true; + const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); + const holds = (label: string) => labels.has(label.trim().toLowerCase()); + return ( + (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && + (filters.review === undefined || + (filters.review === "none" + ? item.reviewDecision === null + : item.reviewDecision === filters.review)) && + (filters.checks === undefined || item.checksState === filters.checks) && + (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && + (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && + (filters.author === undefined || + item.author?.login.toLowerCase() === + resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) + ); +} + +/** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ +function matchesUnsortedListing( + item: GitHubPullRequestListItem, + input: { + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly filters?: PullRequestListFilters | undefined; + }, +): boolean { + const matchesState = input.state === "all" || item.state === input.state; + const viewer = input.viewer.toLowerCase(); + const matchesInvolvement = + input.involvement === "all" || + (input.involvement === "authored" + ? item.author?.login.toLowerCase() === viewer + : item.hasTeamReviewRequest || + item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer)); + return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer); +} + +/** What a repository selector may hold before it goes into a search as itself. */ +const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + +/** + * A listing as one GitHub search, across one repository or several: the only way to read a + * whole host in one request, and the only order (`updated`) a continuation can carry on from. + * + * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is + * `is:closed is:unmerged` here. + * + * Null where a repository is not `owner/name`. A name is written into the query as itself, and a + * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its + * own — so an unaddressable one refuses the whole read rather than being escaped into something + * GitHub might still read. + */ +function searchQuery(input: { + readonly repositories: ReadonlyArray; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + readonly filters?: PullRequestListFilters | undefined; +}): string | null { + if (input.repositories.length === 0) return null; + const repositories = input.repositories.map((repository) => repository.trim()); + if (!repositories.every((repository) => SEARCH_REPOSITORY.test(repository))) return null; + const query = input.query?.trim() ?? ""; + return [ + "is:pr", + // "all" is every state, which `is:pr` already is. + ...(input.state === "open" ? ["is:open"] : []), + ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []), + ...(input.state === "merged" ? ["is:merged"] : []), + ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []), + ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), + ...(query.length === 0 ? [] : [searchPhrase(query)]), + // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. + ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), + ...filterQualifiers(input.filters, input.viewer), + // The order the page reads its rows in, and the only order a continuation can carry on from. + "sort:updated-desc", + ...repositories.map((repository) => `repo:${repository}`), + ].join(" "); +} + +const MERGE_MESSAGE_GRAPHQL_QUERY = ` +query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + isMergeQueueEnabled + headRefOid + viewerMergeBodyText(mergeType: $method) + } + } +}`; + +/** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */ +const decodeRevisionRefs = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + sha: Schema.optional(Schema.String), + parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))), + base: Schema.optional(Schema.Struct({ sha: Schema.String })), + head: Schema.optional(Schema.Struct({ sha: Schema.String })), + }), + ), +); + +const decodeMergeMessageResponse = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + isMergeQueueEnabled: Schema.Boolean, + headRefOid: Schema.String, + viewerMergeBodyText: Schema.String, + }), + }), + }), + }), + ), +); +const decodeMergeMessage = (raw: string) => + Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); + +/** What `gh pr merge` and `gh pr update-branch` read before they act. */ +const ACTION_STATE_GRAPHQL_QUERY = ` +query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + id + headRefOid + isMergeQueueEnabled + mergeStateStatus + baseRef { compare(headRef: $headRef) { behindBy } } + } + } +}`; + +const decodeActionStateResponse = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + id: Schema.String, + headRefOid: Schema.String, + isMergeQueueEnabled: Schema.optional(Schema.Boolean), + mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), + baseRef: Schema.optional( + Schema.NullOr( + Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }), + ), + ), + }), + }), + }), + }), + ), +); +const decodeActionState = (raw: string) => + Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest); + +const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) { + mergePullRequest(input: $input) { clientMutationId } +}`; +const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) { + enablePullRequestAutoMerge(input: $input) { clientMutationId } +}`; +const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) { + updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId } +}`; +const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) { + addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId } +}`; + +const GRAPHQL_MERGE_METHODS = { + merge: "MERGE", + squash: "SQUASH", + rebase: "REBASE", +} as const satisfies Record; + +/** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */ +const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); + +/** The mutations that only need the pull request's node id. */ +const SIMPLE_ACTION_MUTATIONS = { + "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION, + ready: READY_FOR_REVIEW_GRAPHQL_MUTATION, + draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION, + close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION, + reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION, +} as const satisfies Partial>; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const sourceControlHost = yield* SourceControlHost.SourceControlHost; + const fileSystem = yield* FileSystem.FileSystem; + const revalidateChecks = yield* makeChecksRevalidator; + const routingIdentities = new Map< + string, + { + at: number; + value: { accountId: string; viewer: string }; + } + >(); + const identityLocks = new Map(); + const decodeRoutingIdentity = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + id: PositiveInt, + login: TrimmedNonEmptyString, + }), + ), + ); + const captureVerifiedCredential = Effect.fn("GitHubPullRequestApi.captureVerifiedCredential")( + function* (input: { readonly cwd: string; readonly host: string }) { + const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); + const host = input.host.toLowerCase(); + // A missing or signed-out credential keeps its own error, so the page can say which. + const { token, fingerprint: key } = yield* api.credential(host); + const credential = { host, token, credentialFingerprint: key }; + // A cold page may ask several times. Wait per credential and check again after the + // first verification; cancellation releases the next waiter without losing its request. + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 }; + lock.users++; + identityLocks.set(key, lock); + return lock; + }), + (lock) => + lock.gate.withPermit( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cached = routingIdentities.get(key); + if (cached !== undefined && now - cached.at < 10 * 60_000) + return { ...credential, ...cached.value }; + // Pin this read so an auth switch cannot poison its cache entry. + const response = yield* api + .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) + .pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), + Effect.provideService(SourceControlRateLimit.CredentialScope, key), + ); + const identity = yield* decodeRoutingIdentity(response.body).pipe( + Effect.mapError(unavailable), + ); + const value = { accountId: String(identity.id), viewer: identity.login }; + if (routingIdentities.size >= 128) + routingIdentities.delete(routingIdentities.keys().next().value!); + routingIdentities.set(key, { at: now, value }); + return { ...credential, ...value }; + }), + ), + (lock) => + Effect.sync(() => { + lock.users--; + if (lock.users === 0) identityLocks.delete(key); + }), + ); + }, + ); + const withVerifiedCredential: GitHubPullRequestApi["Service"]["withVerifiedCredential"] = ( + input, + use, + ) => + captureVerifiedCredential(input).pipe( + Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => + use({ accountId, viewer, credentialFingerprint }).pipe( + Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), + Effect.provideService(GitHubApi.PinnedGitHubCredential, { + host, + token, + credentialFingerprint, + }), + ), + ), + ); + const getRoutingIdentity: GitHubPullRequestApi["Service"]["getRoutingIdentity"] = (input) => + captureVerifiedCredential(input).pipe( + Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), + ); + + /** + * The pull request's own node id, which is what a mutation against the pull request itself is + * addressed by: a reaction on its description, or a rewrite of its words. + * + * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader + * ticking files viewed would otherwise pay a GraphQL round trip per press. + */ + const nodeIds = new Map(); + + const pullRequestNodeId = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly operation: string; + }): Effect.Effect => { + const { owner, name } = parseRepositorySelector(input.repository); + const key = `${input.host} ${owner}/${name} ${input.number}`; + const held = nodeIds.get(key); + if (held !== undefined) { + // Put back at the end on every hit, so what falls out is the pull request nobody has looked + // at rather than the one being ticked through: a run of cold reads would otherwise evict the + // open review and make it pay a round trip per press. + nodeIds.delete(key); + nodeIds.set(key, held); + return Effect.succeed(held); + } + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: input.operation, + allowReserve: true, + variables: { owner, name, number: input.number }, + query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, + decode: decodePullRequestNodeIdJson, + }).pipe( + Effect.tap((nodeId) => + Effect.sync(() => { + if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) { + const oldest = nodeIds.keys().next().value; + if (oldest !== undefined) nodeIds.delete(oldest); + } + nodeIds.set(key, nodeId); + }), + ), + ); + }; + + /** + * Whether a client-given subject actually belongs to the pull request the request names. A + * subject id is trusted to be whatever node it names, and that node can hang off any pull + * request on the host — so the mutation itself would write wherever the id actually belongs, + * not wherever the request says it does, unless this confirms the two agree first. + */ + const subjectBelongsToPullRequest = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly subjectId: string; + readonly operation: string; + }) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: input.operation, + allowReserve: true, + variables: { owner, name, number: input.number, subjectId: input.subjectId }, + query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, + decode: decodeReactionSubjectScopeJson, + }); + }; + + /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */ + const graphql = (input: { + readonly host: string; + readonly operation: string; + readonly query: string; + readonly variables: Readonly>; + }) => api.graphql(input).pipe(Effect.asVoid); + + const readError = (cwd: string, operation: string, cause: unknown) => + new GitHubPullRequestReadError({ cwd, operation, cause }); + + /** Decodes an answer, reporting a failure against the read that made it. */ + const decodeWith = ( + cwd: string, + operation: string, + decode: (raw: string) => Result.Result, + raw: string, + ): Effect.Effect => { + const decoded = decode(raw.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(readError(cwd, operation, decoded.failure)); + }; + + /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ + const graphqlRead = (input: { + readonly cwd: string; + readonly host: string; + readonly operation: string; + readonly allowReserve?: boolean | undefined; + readonly variables?: Readonly>; + readonly query: string; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + api + .graphql({ + host: input.host, + operation: input.operation, + query: input.query, + ...(input.variables === undefined ? {} : { variables: input.variables }), + ...(input.allowReserve === true ? { allowReserve: true } : {}), + }) + .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw))); + + /** A REST read whose JSON answer is decoded the same way. */ + const restRead = (input: { + readonly cwd: string; + readonly host: string; + readonly operation: string; + readonly path: string; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + api + .rest({ host: input.host, operation: input.operation, path: input.path }) + .pipe( + Effect.flatMap((response) => + decodeWith(input.cwd, input.operation, input.decode, response.body), + ), + ); + + /** + * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed + * files, and still serves those files' hunks here. + * + * A page is a whole number of files, so each one parses on its own; the caller carries on from + * `nextCursor` for as long as GitHub keeps handing pages back. + * + * A named commit is read from the commit endpoint, which lists the same file entries and pages + * them the same way, only wrapped in an object. + */ + const diffFilesPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly page: number; + readonly commit?: string | undefined; + }): Effect.Effect => { + const { owner, name } = parseRepositorySelector(input.repository); + const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; + return api + .rest({ + host: input.host, + operation: "getPullRequestDiff", + path: + input.commit === undefined + ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` + : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, + maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, + timeout: DIFF_TIMEOUT, + }) + .pipe( + Effect.flatMap((response) => { + // Checked before decoding: a byte-truncated response is a JSON prefix, which would + // fail to parse. Nothing of this page can be shown, and an empty patch would render + // as a change with no files rather than as the failure it is; slices already handed + // over stay with the reader either way. + if (response.truncated) { + return Effect.fail( + readError( + input.cwd, + "getPullRequestDiff", + new Error(`Page ${input.page} of the changed files was too large to read.`), + ), + ); + } + return decodeWith( + input.cwd, + "getPullRequestDiff", + input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson, + response.body, + ); + }), + Effect.map((files) => { + // Counted before decoding, so a page whose files all failed to decode still moves on + // rather than pointing the reader back at the page it just read. + const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; + return { + patch: files.patch, + truncated: files.truncated, + nextCursor: morePages ? String(input.page + 1) : null, + ...(files.omittedFileStats.length === 0 + ? {} + : { omittedFileStats: files.omittedFileStats }), + }; + }), + ); + }; + + const getPullRequestDiffFileContents: GitHubPullRequestApi["Service"]["getPullRequestDiffFileContents"] = + (input) => + Effect.gen(function* () { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return yield* new GitHubDiffCommitError({ cwd: input.cwd }); + } + const { owner, name } = parseRepositorySelector(input.repository); + const refsResponse = yield* api.rest({ + host: input.host, + operation: "getPullRequestDiffFileContents", + path: + input.commit === undefined + ? `repos/${owner}/${name}/pulls/${input.number}` + : `repos/${owner}/${name}/commits/${input.commit}`, + timeout: DIFF_TIMEOUT, + }); + const refs = decodeRevisionRefs(refsResponse.body); + // A root commit has no parent, which is an absent old revision. Every file in it is new, + // so that is a usable answer whenever the caller does not need the old side. + const baseRef = Option.isSome(refs) + ? input.commit === undefined + ? refs.value.base?.sha + : (refs.value.parents?.[0]?.sha ?? "") + : undefined; + const headRef = Option.isSome(refs) + ? input.commit === undefined + ? refs.value.head?.sha + : refs.value.sha + : undefined; + const rootCommitNewFile = + input.commit !== undefined && input.changeType === "new" && baseRef === ""; + if ( + headRef === undefined || + (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || + !isCommitSha(headRef) + ) { + return yield* new GitHubDiffRevisionsUnavailableError({ + cwd: input.cwd, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }); + } + + const readFile = (revision: string, filePath: string) => + api + .rest({ + host: input.host, + operation: "getPullRequestDiffFileContents", + accept: "application/vnd.github.raw+json", + path: `repos/${owner}/${name}/contents/${filePath + .split("/") + .map(encodeURIComponent) + .join("/")}?ref=${encodeURIComponent(revision)}`, + maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, + timeout: DIFF_TIMEOUT, + }) + .pipe( + Effect.flatMap((response) => + response.truncated || response.body.includes("\0") || response.invalidUtf8 + ? Effect.fail( + new GitHubDiffFileContentsUnavailableError({ + cwd: input.cwd, + path: filePath, + reason: response.truncated ? "oversized" : "binary", + }), + ) + : Effect.succeed(response.body), + ), + ); + + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readFile(baseRef ?? "", input.oldPath), + input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }); + + /** + * Every check context of the head commit, a page at a time, for a rollup the detail read cut at + * its first hundred. Each page names the head it read, so a push mid-walk fails rather than + * mixing two revisions' checks. + */ + type CheckContextsPage = Result.Result.Success< + ReturnType + >; + const readAllCheckContexts = ( + input: Parameters[0], + allowReserve: boolean, + ) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const { pages, truncated } = yield* readGraphQlPages( + (after, previous: ReadonlyArray) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestDetail", + allowReserve, + variables: { owner, name, number: input.number, after }, + query: pullRequestCheckContextsGraphQlQuery(input.host), + decode: decodePullRequestCheckContextsJson, + }).pipe( + // Each page names the head it read, so a push mid-walk fails rather than mixing + // two revisions' checks. + Effect.filterOrFail( + (page) => previous.length === 0 || page.headSha === previous[0]!.headSha, + () => + readError( + input.cwd, + "getPullRequestDetail", + new Error("Pull request head changed while reading checks."), + ), + ), + ), + { maxPages: CHECK_CONTEXT_PAGES, nextCursor: (page) => page.nextCursor }, + ); + return { + headSha: pages[0]?.headSha ?? null, + contexts: pages.flatMap((page) => page.contexts), + truncated, + }; + }); + + const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return SourceControlRateLimit.Interactive.pipe( + Effect.flatMap((allowReserve) => + graphqlRead({ + allowReserve, + cwd: input.cwd, + host: input.host, + operation: "getPullRequestDetail", + variables: { + owner, + name, + number: input.number, + headRef: `refs/pull/${input.number}/head`, + }, + query: pullRequestCoreGraphQlQuery(input.host), + decode: decodePullRequestCoreJson, + }).pipe( + // Past a hundred checks the first page would let the rest imply success, so the whole + // rollup is walked instead. + Effect.filterOrElse( + (core) => !core.checksTruncated, + (core) => + readAllCheckContexts(input, allowReserve).pipe( + Effect.filterOrFail( + (all) => all.headSha === core.headSha, + () => + readError( + input.cwd, + "getPullRequestDetail", + new Error("Pull request head changed while reading checks."), + ), + ), + Effect.map((all) => ({ + ...core, + ...pullRequestChecksFromContexts(all.contexts), + checksTruncated: all.truncated, + })), + ), + ), + ), + ), + ); + }; + + const workflowApprovalLimit = 1_000; + const workflowApprovalReadError = (cwd: string, cause: unknown) => + readError(cwd, "listWorkflowRunsRequiringApproval", cause); + + /** + * Every open pull request whose head branch carries this name, up to one past the limit. + * `truncated` says some were never read, which is never enough to call a head unique. + */ + const listHeadsByBranch = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly headBranch: string; + }) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray }>) => + pages.reduce((total, page) => total + page.heads.length, 0); + const { pages, truncated } = yield* readGraphQlPages( + (after) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listWorkflowRunsRequiringApproval", + allowReserve: true, + variables: { owner, name, head: input.headBranch, after }, + query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, + decode: decodePullRequestHeadsJson, + }), + { + nextCursor: (page) => page.nextCursor, + until: (pages) => countHeads(pages) > workflowApprovalLimit, + }, + ); + return { heads: pages.flatMap((page) => page.heads), truncated }; + }); + + /** + * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is + * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did. + */ + const listActionRequiredRuns = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly headSha: string; + readonly headBranch: string; + }) => + Effect.gen(function* () { + // The checks revalidator may have just confirmed every run of this head as current. + const known = yield* KnownWorkflowRuns; + if (known !== null && known.headSha === input.headSha) { + return known.runs.flatMap((run) => + // A run waiting on a maintainer reports `completed` with an `action_required` + // conclusion; the `status=action_required` query matches either. + (run.conclusion === "action_required" || run.status === "action_required") && + run.head_branch === input.headBranch + ? [ + { + id: run.id, + name: run.name?.trim() || `Workflow run ${run.id}`, + url: run.html_url?.trim() || null, + }, + ] + : [], + ); + } + const { owner, name } = parseRepositorySelector(input.repository); + const runs: GitHubWorkflowRunApproval[] = []; + for (let page = 1; runs.length <= workflowApprovalLimit; page++) { + const read: GitHubWorkflowRunPage = yield* restRead({ + cwd: input.cwd, + host: input.host, + operation: "listWorkflowRunsRequiringApproval", + path: `repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(input.headSha)}&branch=${encodeURIComponent(input.headBranch)}&event=pull_request&status=action_required&per_page=100&page=${page}`, + decode: decodeWorkflowRunsJson, + }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error))); + runs.push(...read.runs); + if (read.rawCount < 100) break; + } + return runs; + }); + + const listWorkflowRunsRequiringApproval: GitHubPullRequestApi["Service"]["listWorkflowRunsRequiringApproval"] = + (input) => + Effect.all( + [ + listHeadsByBranch(input).pipe( + Effect.flatMap( + ({ + heads, + truncated, + }): Effect.Effect< + GitHubPullRequestHead, + GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError + > => { + const exactHeads = heads.filter( + (pullRequest) => + pullRequest.headSha === input.headSha && + pullRequest.isCrossRepository === true && + pullRequest.headRepositoryOwner?.toLowerCase() === + input.headRepositoryOwner.toLowerCase(), + ); + if (truncated || heads.length > workflowApprovalLimit) { + return Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + cwd: input.cwd, + number: input.number, + reason: "head-list-truncated", + observedCount: heads.length, + limit: workflowApprovalLimit, + }), + ); + } + if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { + return Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + cwd: input.cwd, + number: input.number, + reason: "head-not-unique", + observedCount: exactHeads.length, + limit: workflowApprovalLimit, + }), + ); + } + return Effect.succeed(exactHeads[0]); + }, + ), + ), + listActionRequiredRuns(input).pipe( + Effect.flatMap((runs) => + runs.length > workflowApprovalLimit + ? Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + cwd: input.cwd, + number: input.number, + reason: "run-list-truncated", + observedCount: runs.length, + limit: workflowApprovalLimit, + }), + ) + : Effect.succeed(runs), + ), + ), + ], + { concurrency: 2 }, + ).pipe(Effect.map(([, runs]) => runs)); + + /** One pull request's summary, through the same aliased query the batch uses. */ + const viewPullRequestSummary = (input: PullRequestSummaryRead) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestSummary", + variables: { owner, name, number: input.number }, + query: pullRequestSummaryGraphQlQuery(input.host === "github.com"), + decode: decodePullRequestSummariesJson, + }).pipe( + Effect.flatMap((summaries) => { + const summary = summaries.get(0); + return summary === undefined + ? Effect.fail( + readError( + input.cwd, + "getPullRequestSummary", + new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`), + ), + ) + : Effect.succeed(summary); + }), + ); + }; + + /** + * Summaries asked for together, on one host under one credential, share aliased GraphQL reads + * of twenty-five: the background sync reads every linked pull request each minute, and one + * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector + * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. + */ + const summaryResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + JSON.stringify([ + request.host.toLowerCase(), + Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? null, + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ]), + resolver: (entries) => { + const [first] = entries; + const batchable = entries.filter( + (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, + ); + // Stack membership rides along where GitHub serves stacks, so the background sync can skip + // the REST stack read for pull requests that are in none. + const document = buildPullRequestSummariesGraphQlQuery( + batchable.map((entry) => entry.request), + first.request.host === "github.com", + ); + const batched = + document === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestSummary", + ...document, + decode: decodePullRequestSummariesJson, + }); + return batched.pipe( + // A GraphQL error anywhere fails the whole document — one repository gone or out of + // reach — so a batch that could not be read leaves every entry to its own read. A paused + // budget is the exception: reading one at a time would only spend what is being saved. + Effect.catchCauseIf( + (cause) => + !Cause.hasInterruptsOnly(cause) && + !Cause.findErrorOption(cause).pipe( + Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), + ), + (cause) => + Effect.logDebug("batched pull request summary read failed", { cause }).pipe( + Effect.as(new Map()), + ), + ), + Effect.flatMap((summaries) => { + const unanswered = entries.filter((entry) => { + const summary = summaries.get(batchable.indexOf(entry)); + if (summary === undefined) return true; + entry.completeUnsafe(Exit.succeed(summary)); + return false; + }); + return Effect.forEach( + unanswered, + (entry) => + viewPullRequestSummary(entry.request).pipe( + Effect.exit, + Effect.map((exit) => entry.completeUnsafe(exit)), + ), + { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, + ); + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe( + RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), + RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), + ); + const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = (input) => + Effect.request(new PullRequestSummaryRead(input), summaryResolver); + + // Every watched pull request on a host in one read per pass. A pull request the batch has no + // answer for gets null, and its watch reads it in full; a failed batch fails every entry. + const watchFingerprintResolver = RequestResolver.makeGrouped< + PullRequestWatchFingerprintRead, + string + >({ + key: ({ request, context }) => + JSON.stringify([ + request.host.toLowerCase(), + Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? null, + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ]), + resolver: (entries) => { + const [first] = entries; + const batchable = entries.filter( + (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, + ); + const document = buildPullRequestWatchFingerprintsGraphQlQuery( + batchable.map((entry) => entry.request), + ); + const read = + document === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestWatchFingerprint", + ...document, + decode: decodePullRequestWatchFingerprintsJson, + }); + return read.pipe( + Effect.map((fingerprints) => { + for (const entry of entries) { + const index = batchable.indexOf(entry); + entry.completeUnsafe( + Exit.succeed(index === -1 ? null : (fingerprints.get(index) ?? null)), + ); + } + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe( + RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), + RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), + ); + const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] = + (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver); + + return GitHubPullRequestApi.of({ + withVerifiedCredential, + revalidateChecks, + getRoutingIdentity, + getViewerLogin: (input) => + getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)), + + listPullRequests: (input) => { + const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); + const { owner, name } = parseRepositorySelector(input.repository); + const query = searchQuery({ ...input, repositories: [input.repository] }); + if (query === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + cwd: input.cwd, + operation: "listPullRequests", + }), + ); + } + /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */ + const collect = ( + rows: number, + page: ( + after: string | null, + rows: number, + ) => Effect.Effect, + ) => + Effect.gen(function* () { + const countRows = (pages: ReadonlyArray) => + pages.reduce((total, read) => total + read.rawCount, 0); + const { pages, truncated } = yield* readGraphQlPages( + (after, pages: ReadonlyArray) => + page(after, rows - countRows(pages)), + { + nextCursor: (read) => read.endCursor, + until: (pages) => countRows(pages) >= rows, + }, + ); + // `truncated` also covers a cursor GitHub repeated before `rows` arrived. + return { + items: pages.flatMap((read) => read.items), + rawCount: countRows(pages), + truncated, + }; + }); + const read = ( + continues: boolean, + requestedRows = input.limit + 1, + ): Effect.Effect => + collect(requestedRows, (after, rows) => + continues + ? graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequests", + // The reader's own words are in the query. + variables: { q: query, after }, + query: pullRequestSearchGraphQlQuery(rows, false, true), + decode: decodePullRequestSearchJson, + }) + : graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequests", + variables: { owner, name, states: LIST_STATES[input.state], after }, + query: pullRequestListGraphQlQuery(rows), + decode: decodePullRequestListJson, + }), + ).pipe( + Effect.flatMap(({ items: rawItems, rawCount, truncated: cutShort }) => { + const items = continues + ? rawItems + : rawItems.filter((item) => matchesUnsortedListing(item, input)); + if ( + !continues && + items.length < input.limit && + rawCount >= requestedRows && + requestedRows < fallbackMaxRows + ) { + const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); + if (nextRows > requestedRows) return read(false, nextRows); + } + return Effect.succeed({ + items: items.slice(0, input.limit), + // One row over the page size is the probe for a next page, and it is + // counted before decoding: a skipped malformed row must not end paging. + truncated: + cutShort || + (continues + ? rawCount > input.limit + : items.length > input.limit || rawCount >= requestedRows), + continues, + }); + }), + ); + // GitHub does not index every repository for search, and one it will not search answers + // with no rows rather than with an error — so an empty listing is read again from the + // repository's own list. Those rows come back newest-created first, an order no `updated:` + // qualifier can carry on from, so that page says it cannot be continued and the reader + // reaches the rest of it by asking for a larger page, as every listing used to. + // + // Only ever the first slice: a repository that answered the search once will answer it + // again, so an empty slice under a cursor is a repository that has run out. + // A text search that finds nothing has found nothing: falling back would answer it with the + // repository's whole list, which is every row the reader did not search for. The fallback + // is for a repository the index does not cover, and a listing with no text to match is the + // only place an empty answer can mean that. + // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just + // as well as search judges them over its own, so carrying them into the fallback answers + // the same read rather than a wider one. Free text is the one thing the fallback cannot + // judge locally — it lists rows, it does not search their text — so a query still rules + // the fallback out: an empty answer under one is already the answer. + const hasQuery = (input.query?.trim().length ?? 0) > 0; + return read(true).pipe( + Effect.filterOrElse( + (batch) => batch.items.length > 0 || input.cursor !== undefined || hasQuery, + () => read(false), + ), + Effect.flatMap((batch) => { + // Match the search query's host support, and enrich only rows that survived paging. + if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); + const chunks: Array> = []; + for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { + chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); + } + return Effect.forEach( + chunks, + (chunk) => { + const document = buildPullRequestStackMembershipsGraphQlQuery( + input.repository, + chunk.map((item) => item.number), + ); + if (document === null) return Effect.succeed(chunk); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequestStackMemberships", + ...document, + decode: decodePullRequestStackMembershipsJson, + }).pipe( + Effect.map((memberships) => + chunk.map((item, index) => { + const stack = memberships.get(index); + return stack === undefined ? item : { ...item, stack }; + }), + ), + // Optional badges must not take down a listing that already read successfully. + Effect.catch(() => + Effect.logWarning("Pull request stack membership enrichment failed", { + operation: "listPullRequestStackMemberships", + host: input.host, + rows: chunk.length, + }).pipe(Effect.as(chunk)), + ), + ); + }, + { concurrency: STAT_REQUEST_CONCURRENCY }, + ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); + }), + ); + }, + + searchPullRequests: (input) => { + const query = searchQuery(input); + if (query === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + cwd: input.cwd, + operation: "searchPullRequests", + }), + ); + } + // One extra row reveals that the host has more than the slice shows, the way the + // per-repository read does — up to GitHub's own ceiling on a search page, past which + // `hasNextPage` is what says there is more. + const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "searchPullRequests", + variables: { q: query, after: null }, + query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), + decode: decodePullRequestSearchJson, + }).pipe( + Effect.map((batch) => ({ + items: batch.items.slice(0, input.limit), + truncated: batch.rawCount > input.limit || batch.hasNextPage, + })), + ); + }, + + listPullRequestStats: (input) => { + const chunks: Array> = + []; + for (let start = 0; start < input.changeRequests.length; start += STAT_ALIASES_PER_REQUEST) { + chunks.push(input.changeRequests.slice(start, start + STAT_ALIASES_PER_REQUEST)); + } + return Effect.forEach( + chunks, + (chunk) => { + const document = buildPullRequestStatsGraphQlQuery(chunk); + if (document === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + cwd: input.cwd, + operation: "listPullRequestStats", + }), + ); + } + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequestStats", + ...document, + decode: decodePullRequestStatsJson, + }).pipe( + Effect.map((stats) => + chunk.flatMap((changeRequest, index) => { + const stat = stats.get(index); + return stat === undefined ? [] : [{ ...changeRequest, ...stat }]; + }), + ), + ); + }, + { concurrency: STAT_REQUEST_CONCURRENCY }, + ).pipe(Effect.map((results) => results.flat())); + }, + + getPullRequestSummary, + getPullRequestWatchFingerprint, + + getPullRequestDetail, + getPullRequestPreview: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestPreview", + variables: { owner, name, number: input.number }, + query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, + decode: decodePullRequestPreviewJson, + }); + }, + listWorkflowRunsRequiringApproval, + + getPullRequestStack: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return restRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestStack", + path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`, + decode: decodePullRequestStacksJson, + }).pipe( + // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. + Effect.flatMap((stack) => { + if (!input.includeDetails || stack === null) return Effect.succeed(stack); + return restRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestStack", + path: `repos/${owner}/${name}/stacks/${stack.number}`, + decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), + }); + }), + // Hosts without the stacks preview return 404. Other failures must preserve the + // previously synced stack and let the caller retry. + Effect.catchTags({ + GitHubApiNotFoundError: () => Effect.succeed(null), + }), + ); + }, + + getPullRequestActivity: (input) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + let author: PullRequestActor | null = null; + let commits: ReadonlyArray = []; + const remarks: PullRequestComment[] = []; + let commentsAfter: string | null = null; + let reviewsAfter: string | null = null; + let withComments = true; + let withReviews = true; + // Both remark lists page on their own; a page asks only for the ones with more to give. + for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { + const read: GitHubPullRequestActivityPage = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestActivity", + variables: { + owner, + name, + number: input.number, + head: page === 0, + withComments, + commentsAfter, + withReviews, + reviewsAfter, + }, + query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, + decode: decodePullRequestActivityJson, + }); + if (page === 0) { + author = read.author ?? null; + commits = read.commits ?? []; + } + remarks.push(...read.remarks); + commentsAfter = read.nextCommentsCursor; + reviewsAfter = read.nextReviewsCursor; + withComments = withComments && commentsAfter !== null; + withReviews = withReviews && reviewsAfter !== null; + } + return { + author, + comments: remarks.toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commits, + } satisfies GitHubPullRequestActivity; + }), + + getPullRequestDiff: (input) => { + const filesPage = (page: number) => + diffFilesPage({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + page, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }); + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd })); + } + // A cursor only ever comes from the files walk, so a reader carrying one is already past + // the point where `gh pr diff` had anything to say. + if (input.cursor !== undefined) { + const page = diffCursorPage(input.cursor); + return page === null + ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd })) + : filesPage(page); + } + // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. + if (input.commit !== undefined) { + return filesPage(1); + } + const { owner, name } = parseRepositorySelector(input.repository); + return api + .rest({ + host: input.host, + operation: "getPullRequestDiff", + path: `repos/${owner}/${name}/pulls/${input.number}`, + accept: "application/vnd.github.diff", + maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, + timeout: DIFF_TIMEOUT, + }) + .pipe( + Effect.flatMap((response) => + // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor + // something the reader can carry on from. The files API can serve the same change a + // whole number of files at a time, so an oversized patch takes that road as well. + response.truncated + ? filesPage(1) + : // One read served the whole patch, so there is no next slice to ask for. + Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), + ), + // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read + // from the files API instead, a page per call. Only once the direct read has failed: a + // pull request GitHub will serve a diff for must not pay for a second request. A + // fallback that fails too reports the original refusal, which is the one that explains + // the page. Narrowed to a request GitHub answered and refused: a missing credential or + // a rate limit fails the same way for every request. + Effect.catchTags({ + GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), + }), + ); + }, + + getPullRequestDiffFileContents, + + getReviewThreadComments: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getReviewThreadComments", + variables: { + owner, + name, + number: input.number, + threadId: input.threadId, + cursor: input.cursor, + }, + query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, + decode: decodeReviewThreadCommentsJson, + }).pipe( + Effect.flatMap(({ belongsToPullRequest, comments, nextCursor }) => + belongsToPullRequest + ? Effect.succeed({ comments, nextCursor }) + : Effect.fail( + new GitHubSubjectScopeError({ + cwd: input.cwd, + operation: "getReviewThreadComments", + }), + ), + ), + ); + }, + + listReviewThreadComments: (input) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const threadPage = ( + cursor: string | null, + ): Effect.Effect => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: { owner, name, number: input.number, cursor }, + query: REVIEW_THREADS_GRAPHQL_QUERY, + decode: decodeReviewThreadsJson, + }); + const entries: GitHubReviewThreadEntry[] = []; + const avatarsByLogin = new Map(); + const botLogins = new Set(); + const commitStats = new Map< + string, + { readonly additions: number; readonly deletions: number } + >(); + let reviewers: ReadonlyArray = []; + let reactions: GitHubReviewThreadPage["reactions"] = []; + const reactionsById = new Map>(); + const editedAtById = new Map(); + let commits: GitHubReviewThreadPage["commits"] = []; + let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; + const dismissalsByReviewId = new Map(); + let dismissalCursor: string | null = null; + let cursor: string | null = null; + let page = 0; + do { + const read: GitHubReviewThreadPage = yield* threadPage(cursor); + entries.push(...read.threads); + for (const login of read.botLogins) botLogins.add(login); + for (const [login, avatarUrl] of read.avatarsByLogin) + avatarsByLogin.set(login, avatarUrl); + // The roster, the commits and the viewer's standing travel with every page, and the + // first one already carries all of them. + if (page === 0) { + reviewers = read.reviewers; + reactions = read.reactions; + for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry); + for (const [id, editedAt] of read.editedAtById) editedAtById.set(id, editedAt); + commits = read.commits; + viewer = read.viewer; + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); + dismissalCursor = read.nextDismissalCursor; + for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); + } + cursor = read.nextCursor; + page += 1; + } while (cursor !== null && page < REVIEW_THREAD_PAGES); + + // Almost never entered: the embedded page already holds every dismissal a pull request + // ordinarily accrues. Followed so a review whose event fell past that page still finds + // its reason. + if (dismissalCursor !== null) { + const { pages } = yield* readGraphQlPages( + (cursor) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: { owner, name, number: input.number, cursor }, + query: REVIEW_DISMISSALS_GRAPHQL_QUERY, + decode: decodeReviewDismissalsJson, + }), + { + from: dismissalCursor, + maxPages: REVIEW_THREAD_PAGES, + nextCursor: (read) => read.nextCursor, + }, + ); + for (const read of pages) + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); + } + + const reviewThreads = entries.map((entry) => ({ + ...entry.thread, + commentCount: entry.commentCount, + ...(entry.nextCommentCursor === null + ? {} + : { nextCommentsCursor: entry.nextCommentCursor }), + })); + return { + comments: reviewThreadConversation(reviewThreads), + dismissalsByReviewId, + reviewThreads, + // GitHub's own count of each thread, so the number the page shows is the host's even + // where a bound kept some of the words on GitHub. + commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0), + truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null), + reviewThreadsTruncated: cursor !== null, + reactions, + reactionsById, + editedAtById, + reviewers, + avatarsByLogin, + botLogins, + commitStats, + commits, + viewer, + }; + }), + + listActorAvatars: (input) => { + if (input.ids.length === 0) { + return Effect.succeed(new Map()); + } + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listActorAvatars", + variables: { ids: input.ids }, + query: ACTOR_AVATARS_GRAPHQL_QUERY, + decode: decodeActorAvatarsJson, + }); + }, + + getViewerAccess: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getViewerAccess", + ...(input.allowReserve === true ? { allowReserve: true } : {}), + variables: { owner, name, number: input.number }, + query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, + decode: decodeViewerPermissionsJson, + }); + }, + + listReviewerCandidates: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewerCandidates", + allowReserve: true, + variables: { owner, name, number: input.number }, + query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, + decode: decodeReviewerCandidatesJson, + }); + }, + + setReviewerRequest: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + // Posting to a login GitHub has already been asked about is what a re-request is, so + // there is nothing to say here about somebody who has reviewed once already. + return api + .rest({ + host: input.host, + operation: "setReviewerRequest", + method: input.requested ? "POST" : "DELETE", + path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, + body: buildReviewerRequest(input.reviewers), + }) + .pipe(Effect.asVoid); + }, + + listLabelCandidates: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listLabelCandidates", + allowReserve: true, + variables: { owner, name, number: input.number }, + query: LABEL_CANDIDATES_GRAPHQL_QUERY, + decode: decodeLabelCandidatesJson, + }); + }, + + setLabels: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + // A pull request is an issue to the labels API. Adding posts a list and leaves what was + // already there; taking off is one delete per label, since the endpoint names one in its + // path. The name goes into the path encoded, because a label may carry a space or a slash. + const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; + if (input.applied) { + return api + .rest({ + host: input.host, + operation: "setLabels", + method: "POST", + path: issue, + body: buildLabelRequest(input.labels), + }) + .pipe(Effect.asVoid); + } + return Effect.forEach( + input.labels, + (label) => + api.rest({ + host: input.host, + operation: "setLabels", + method: "DELETE", + path: `${issue}/${encodeURIComponent(label)}`, + }), + { concurrency: 1, discard: true }, + ); + }, + + runPullRequestAction: (input) => { + if (input.stackNumber !== undefined) + return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( + Effect.provideService(GitHubApi.GitHubApi, api), + Effect.provideService(SourceControlHost.SourceControlHost, sourceControlHost), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + if (input.action === "revert") { + return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + host: input.host, + operation: "revertPullRequest", + query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, + variables: { pullRequestId }, + }), + ), + ); + } + if (input.action === "approve-workflows") { + const { owner, name } = parseRepositorySelector(input.repository); + return getPullRequestDetail(input).pipe( + Effect.flatMap((detail) => { + if (detail.isCrossRepository !== true) return Effect.void; + if (detail.headSha == null || detail.headRepositoryOwner == null) { + return Effect.fail( + new GitHubWorkflowApprovalHeadUnavailableError({ + cwd: input.cwd, + number: input.number, + }), + ); + } + const expectedHeadSha = detail.headSha; + const expectedHeadBranch = detail.headBranch; + const expectedHeadRepositoryOwner = detail.headRepositoryOwner; + return listWorkflowRunsRequiringApproval({ + ...input, + headSha: expectedHeadSha, + headBranch: expectedHeadBranch, + headRepositoryOwner: expectedHeadRepositoryOwner, + isCrossRepository: true, + }).pipe( + Effect.flatMap((runs) => + Effect.forEach( + runs, + (run) => + getPullRequestDetail(input).pipe( + Effect.flatMap((current) => { + if (current.headSha == null || current.headRepositoryOwner == null) { + return Effect.fail( + new GitHubWorkflowApprovalHeadUnavailableError({ + cwd: input.cwd, + number: input.number, + }), + ); + } + if ( + current.isCrossRepository !== true || + current.headSha !== expectedHeadSha || + current.headBranch !== expectedHeadBranch || + current.headRepositoryOwner.toLowerCase() !== + expectedHeadRepositoryOwner.toLowerCase() + ) { + return Effect.fail( + new GitHubWorkflowApprovalHeadChangedError({ + cwd: input.cwd, + number: input.number, + }), + ); + } + return listWorkflowRunsRequiringApproval({ + ...input, + headSha: current.headSha, + headBranch: current.headBranch, + headRepositoryOwner: current.headRepositoryOwner, + isCrossRepository: true, + }); + }), + Effect.flatMap((currentRuns) => + currentRuns.some((current) => current.id === run.id) + ? api + .rest({ + host: input.host, + operation: "approveWorkflowRun", + method: "POST", + path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`, + }) + .pipe(Effect.asVoid) + : Effect.void, + ), + ), + { concurrency: 1, discard: true }, + ), + ), + ); + }), + ); + } + const action = input.action; + if (action in SIMPLE_ACTION_MUTATIONS) { + return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + host: input.host, + operation: "runPullRequestAction", + query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS], + variables: { pullRequestId }, + }), + ), + ); + } + return Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + // Read fresh rather than from the node id cache: merging and updating act on the head + // as it stands now, and the merge queue decides which mutation a merge is. + const state = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "runPullRequestAction", + allowReserve: true, + query: ACTION_STATE_GRAPHQL_QUERY, + variables: { + owner, + name, + number: input.number, + headRef: `refs/pull/${input.number}/head`, + }, + decode: decodeActionState, + }); + if (action === "update-branch") { + // Already current is done, the way `gh pr update-branch` reports it. + if (state.baseRef?.compare?.behindBy === 0) return; + // GitHub updates with a merge commit unless asked to rebase, which is its own default. + return yield* graphql({ + host: input.host, + operation: "runPullRequestAction", + query: UPDATE_BRANCH_GRAPHQL_MUTATION, + variables: { + pullRequestId: state.id, + expectedHeadOid: state.headRefOid, + updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE", + }, + }); + } + let body: string | undefined; + let expectedHead: string | undefined; + if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { + const message = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "runPullRequestAction", + allowReserve: true, + query: MERGE_MESSAGE_GRAPHQL_QUERY, + variables: { + owner, + name, + number: input.number, + method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE", + }, + decode: decodeMergeMessage, + }); + // GitHub's merge queue chooses its own message and ignores custom text. + if (!message.isMergeQueueEnabled) { + const cleaned = removeAgentCredits(message.viewerMergeBodyText); + if (cleaned !== message.viewerMergeBodyText) { + body = cleaned; + expectedHead = message.headRefOid; + } + } + } + // A merge queue takes a pull request through auto-merge rather than a direct merge, and + // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. + // GitHub stores the strategy with a standing instruction rather than choosing one at + // merge time, so arming still names it. + const auto = + state.isMergeQueueEnabled === true || + (action === "enable-auto-merge" && + !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); + yield* graphql({ + host: input.host, + operation: "runPullRequestAction", + query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION, + variables: { + input: { + pullRequestId: state.id, + mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"], + ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }), + ...(body === undefined ? {} : { commitBody: body }), + }, + }, + }); + }); + }, + + commentOnPullRequest: (input) => + pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe( + Effect.flatMap((subjectId) => + graphql({ + host: input.host, + operation: "commentOnPullRequest", + query: ADD_COMMENT_GRAPHQL_MUTATION, + variables: { subjectId, body: input.body }, + }), + ), + ), + + submitReview: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + // The whole review is one request, so nothing is visible to anyone else until the verdict + // is sent. + return api + .rest({ + host: input.host, + operation: "submitReview", + method: "POST", + path: `repos/${owner}/${name}/pulls/${input.number}/reviews`, + body: buildReviewSubmission({ + verdict: input.verdict, + body: input.body, + comments: input.comments, + }), + }) + .pipe(Effect.asVoid); + }, + + replyToReviewThread: (input) => + graphql({ + host: input.host, + operation: "replyToReviewThread", + query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, + variables: { threadId: input.threadId, body: input.body }, + }), + + getPullRequestFilesViewed: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return readGraphQlPages( + (after) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestFilesViewed", + variables: { owner, name, number: input.number, after }, + query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, + decode: decodePullRequestFilesViewedJson, + }), + { maxPages: FILES_VIEWED_MAX_PAGES, nextCursor: (page) => page.nextCursor }, + ).pipe( + Effect.map(({ pages, truncated }) => ({ + files: pages.flatMap((page) => page.files), + truncated, + })), + ); + }, + + setPullRequestFilesViewed: (input) => { + if (input.files.length === 0) return Effect.void; + return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( + Effect.flatMap((pullRequestId) => { + const mutation = buildSetFilesViewedGraphQlMutation(pullRequestId, input.files); + return mutation === null + ? Effect.void + : graphql({ host: input.host, operation: "setPullRequestFilesViewed", ...mutation }); + }), + ); + }, + + setReviewThreadResolution: (input) => + graphql({ + host: input.host, + operation: "setReviewThreadResolution", + query: input.resolved + ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION + : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + variables: { threadId: input.threadId }, + }), + + setReaction: (input) => { + const givenSubjectId = input.subjectId; + const subjectId = + givenSubjectId === undefined + ? pullRequestNodeId({ ...input, operation: "setReaction" }) + : subjectBelongsToPullRequest({ + ...input, + subjectId: givenSubjectId, + operation: "setReaction", + }).pipe( + Effect.flatMap((belongs) => + belongs + ? Effect.succeed(givenSubjectId) + : Effect.fail( + new GitHubSubjectScopeError({ + cwd: input.cwd, + operation: "setReaction", + }), + ), + ), + ); + return subjectId.pipe( + Effect.flatMap((subjectId) => + graphql({ + host: input.host, + operation: "setReaction", + query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, + variables: { subjectId, content: gitHubReactionContent(input.content) }, + }), + ), + ); + }, + + updatePullRequest: (input) => + pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + host: input.host, + operation: "updatePullRequest", + query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, + // A field the caller did not name is left out of the request entirely, so GitHub + // keeps the words that are there rather than being asked for an empty one. + variables: { + pullRequestId, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }, + }), + ), + ), + + updateComment: (input) => + subjectBelongsToPullRequest({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + subjectId: input.commentId, + operation: "updateComment", + }).pipe( + Effect.flatMap((belongs) => + belongs + ? Effect.succeed(input.commentId) + : Effect.fail( + new GitHubSubjectScopeError({ + cwd: input.cwd, + operation: "updateComment", + }), + ), + ), + Effect.flatMap((commentId) => + graphql({ + host: input.host, + operation: "updateComment", + query: + input.kind === "issue-comment" + ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION + : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, + variables: { commentId, body: input.body }, + }), + ), + ), + }); +}); From 770a33b8196d8e80d05a59ff6632968b1d4fa62b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 07:19:23 +0000 Subject: [PATCH 005/241] perf(cli): avoid server imports and cache native executable code --- apps/server/src/bin.ts | 24 ++++++- apps/server/src/binLightCli.ts | 35 +++++++++++ apps/server/src/cli/claudeHistory.ts | 11 ++-- apps/server/src/cli/pair.ts | 2 +- apps/server/src/cli/project.test.ts | 6 +- apps/server/src/cli/project.ts | 7 ++- apps/server/src/cli/server.ts | 2 +- apps/server/src/cliCompileCache.ts | 38 +++++++++++ apps/server/src/compileCache.test.ts | 63 +++++++++++++++++++ .../DelegatedCompletionDelivery.test.ts | 5 +- ...ProjectSettingsUpgrade.integration.test.ts | 5 +- .../src/orchestration-v2/projectLayers.ts | 53 ++++++++++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 17 ++--- .../src/orchestration-v2/runtimeLayer.ts | 55 +++------------- .../src/project/ManagedProjectFolders.test.ts | 4 +- .../server/src/project/ProjectService.test.ts | 10 +-- apps/server/src/server.ts | 41 +++++++----- apps/server/vite.config.ts | 22 +++++-- 18 files changed, 299 insertions(+), 101 deletions(-) create mode 100644 apps/server/src/binLightCli.ts create mode 100644 apps/server/src/cliCompileCache.ts create mode 100644 apps/server/src/orchestration-v2/projectLayers.ts diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index bb4a429800e88..006d2626609bb 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -8,6 +8,9 @@ * the real CLI in ./binCli.ts. The server's spawn host (`t3 __spawn-host`) * starts here for the same reason, and to stay small. */ +import packageJson from "../package.json" with { type: "json" }; +import * as NodeSea from "node:sea"; +import { enableCliCompileCache } from "./cliCompileCache.ts"; import { isEntrypoint } from "./entrypoint.ts"; if ( @@ -18,7 +21,26 @@ if ( }) ) { const command = process.argv[2]; - if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { + if (!(process.argv.length === 3 && command === "--version")) { + enableCliCompileCache({ + bundled: NodeSea.isSea() || import.meta.url.endsWith(".mjs"), + argv: process.argv.slice(2), + environment: process.env, + }); + } + if (process.argv.length === 3 && command === "--version") { + process.stdout.write(`t3 v${packageJson.version}\n`); + } else if ( + command === "pair" || + command === "auth" || + command === "service" || + command === "project" || + command === "serve" || + command === "start" + ) { + const { runLightCli } = await import("./binLightCli.ts"); + await runLightCli(command); + } else if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts"); await runAcpMcpCliFastPath(command, process.argv.slice(3)); } else if (command === "__spawn-host") { diff --git a/apps/server/src/binLightCli.ts b/apps/server/src/binLightCli.ts new file mode 100644 index 0000000000000..0e42193052b4b --- /dev/null +++ b/apps/server/src/binLightCli.ts @@ -0,0 +1,35 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Command } from "effect/cli"; +import * as NetService from "@t3tools/shared/Net"; +import packageJson from "../package.json" with { type: "json" }; +import { sharedServerCommandFlags } from "./cli/config.ts"; + +/** Load only the requested command; keep parsing and execution in the existing CLI. */ +export async function runLightCli( + command: "pair" | "auth" | "service" | "project" | "serve" | "start", +) { + const selected = + command === "pair" + ? (await import("./cli/pair.ts")).pairCommand + : command === "auth" + ? (await import("./cli/auth.ts")).authCommand + : command === "service" + ? (await import("./cli/service.ts")).serviceCommand + : command === "project" + ? (await import("./cli/project.ts")).projectCommand + : command === "serve" + ? (await import("./cli/server.ts")).serveCommand + : (await import("./cli/server.ts")).startCommand; + const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withSubcommands([selected]), + ); + Command.run(cli, { version: packageJson.version }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer)), + NodeRuntime.runMain, + ); +} diff --git a/apps/server/src/cli/claudeHistory.ts b/apps/server/src/cli/claudeHistory.ts index b48ed2818a598..5d9427d12d1e2 100644 --- a/apps/server/src/cli/claudeHistory.ts +++ b/apps/server/src/cli/claudeHistory.ts @@ -1,8 +1,6 @@ import * as Effect from "effect/Effect"; import { Argument, Command } from "effect/cli"; -import { runClaudeHistoryWorker } from "../claudeHistoryWorker.ts"; - /** * Hosts the Claude history worker inside the CLI executable. The npm bundle * runs it as a sibling `claudeHistoryWorker.mjs` under the host Node; the @@ -16,12 +14,13 @@ export const claudeHistoryCommand = Command.make("__claude-history", { }).pipe( Command.unlisted, Command.withHandler(({ method, sessionId, options }) => - Effect.promise(() => - runClaudeHistoryWorker( + Effect.promise(async () => { + const { runClaudeHistoryWorker } = await import("../claudeHistoryWorker.ts"); + return runClaudeHistoryWorker( method, sessionId, options._tag === "Some" ? options.value : undefined, - ), - ), + ); + }), ), ); diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index debac218a9838..1a733ee5809c4 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -21,7 +21,6 @@ import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { buildTailscaleHttpsBaseUrl, DEFAULT_TAILSCALE_SERVE_PORT, - ensureTailscaleServe, readTailscaleStatus, } from "@t3tools/tailscale"; import * as Config from "effect/Config"; @@ -399,6 +398,7 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase" if (isDevServerNotProxiableError(localTarget)) { return yield* localTarget; } + const { ensureTailscaleServe } = yield* Effect.promise(() => import("@t3tools/tailscale")); yield* ensureTailscaleServe({ localPort: localTarget.localPort, servePort: input.servePort, diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index 965787005ae01..48149d31b4de3 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -1,3 +1,4 @@ +import * as ProjectLayers from "../orchestration-v2/projectLayers.ts"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; // @effect-diagnostics nodeBuiltinImport:off - CLI integration uses temporary Node paths. @@ -30,7 +31,6 @@ import * as ServerConfig from "../config.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; -import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; @@ -87,7 +87,7 @@ const makeConfig = (baseDir: string) => const readProjects = (baseDir: string) => Effect.gen(function* () { const config = yield* makeConfig(baseDir); - const layer = RuntimeLayer.layerProjectService.pipe( + const layer = ProjectLayers.layerProjectService.pipe( Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(ProjectFaviconResolver.layer), @@ -168,7 +168,7 @@ const makeThreadPersistenceLayer = Effect.fn("ProjectCliTest.makeThreadPersisten function* (baseDir: string) { const config = yield* makeConfig(baseDir); return Layer.mergeAll( - RuntimeLayer.layerEventSink, + ProjectLayers.layerEventSink, ProjectionStore.layer, EventStore.layer, ).pipe( diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index afa37440c79f3..b9f7dba6474f0 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -25,7 +25,6 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerConfig from "../config.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; @@ -196,7 +195,11 @@ const projectCommandUuid = Crypto.Crypto.pipe( ), ); -const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe( +const layerProjectCliRuntime = Layer.unwrap( + Effect.promise(() => import("../orchestration-v2/projectLayers.ts")).pipe( + Effect.map((runtime) => runtime.layerProjectService), + ), +).pipe( Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge( diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index 8c8dd9237cb91..ea0d6db5d6e6c 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -7,7 +7,6 @@ import { Command, GlobalFlag } from "effect/cli"; import * as CliError from "effect/cli/CliError"; import * as ServerConfig from "../config.ts"; -import { runServer } from "../server.ts"; import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts"; const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); @@ -23,6 +22,7 @@ const runServerCommand = ( Effect.gen(function* () { const logLevel = yield* GlobalFlag.LogLevel; const config = yield* resolveServerConfig(flags, logLevel, options); + const { runServer } = yield* Effect.promise(() => import("../server.ts")); return yield* runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config)); }); diff --git a/apps/server/src/cliCompileCache.ts b/apps/server/src/cliCompileCache.ts new file mode 100644 index 0000000000000..d0d8ad3100184 --- /dev/null +++ b/apps/server/src/cliCompileCache.ts @@ -0,0 +1,38 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Runs before loading the Effect CLI or server graph. +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +/** Source dev stays uncached; bundled and SEA dependencies persist under the selected T3 home. */ +export function enableCliCompileCache(input: { + readonly bundled: boolean; + readonly argv: readonly string[]; + readonly environment: NodeJS.ProcessEnv; +}) { + if ( + !input.bundled || + input.environment.NODE_DISABLE_COMPILE_CACHE === "1" || + input.environment.NODE_COMPILE_CACHE + ) + return; + const index = input.argv.indexOf("--base-dir"); + const configured = + (index >= 0 ? input.argv[index + 1] : undefined) ?? + input.argv + .find((argument) => argument.startsWith("--base-dir=")) + ?.slice("--base-dir=".length) ?? + input.environment.T3CODE_HOME; + const home = NodeOS.homedir(); + const base = configured?.trim(); + const directory = !base + ? NodePath.join(home, ".t3") + : base === "~" + ? home + : base.startsWith("~/") || base.startsWith("~\\") + ? NodePath.join(home, base.slice(2)) + : NodePath.resolve(base); + // Cache failures never prevent CLI or server startup, including a read-only home. + try { + NodeModule.enableCompileCache(NodePath.join(directory, "compile-cache")); + } catch {} +} diff --git a/apps/server/src/compileCache.test.ts b/apps/server/src/compileCache.test.ts index 17827bb38ba95..10a544786f450 100644 --- a/apps/server/src/compileCache.test.ts +++ b/apps/server/src/compileCache.test.ts @@ -51,3 +51,66 @@ process.kill(process.pid, "SIGKILL");`, } }, ); + +it.each([false, true])( + "selects the CLI cache under the explicit T3 home (disabled: %s)", + async (disabled) => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-cli-cache-")); + try { + const child = NodeChildProcess.spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + ` +const { enableCliCompileCache } = await import(${JSON.stringify(new URL("./cliCompileCache.ts", import.meta.url).href)}); +enableCliCompileCache({ bundled: true, argv: ["serve", "--base-dir", ${JSON.stringify(directory)}], environment: process.env }); +await import(${JSON.stringify(import.meta.resolve("effect/Effect"))}); +const { flushCompileCache } = await import(${JSON.stringify(new URL("./compileCache.ts", import.meta.url).href)}); +const Effect = await import(${JSON.stringify(import.meta.resolve("effect/Effect"))}); +await Effect.runPromise(flushCompileCache); +process.kill(process.pid, "SIGKILL");`, + ], + { + encoding: "utf8", + env: { + ...process.env, + NODE_COMPILE_CACHE: undefined, + NODE_DISABLE_COMPILE_CACHE: disabled ? "1" : undefined, + }, + }, + ); + assert.equal(child.error, undefined); + assert.equal(child.stderr, ""); + const entries = await NodeFSP.readdir(NodePath.join(directory, "compile-cache"), { + recursive: true, + }).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return []; + throw error; + }); + const files = await Promise.all( + entries.map((entry) => NodeFSP.stat(NodePath.join(directory, "compile-cache", entry))), + ); + assert.equal( + files.some((entry) => entry.isFile()), + !disabled, + ); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }, +); + +it("preserves the version output through the thin CLI entry", async () => { + const packageJson = JSON.parse( + await NodeFSP.readFile(new URL("../package.json", import.meta.url), "utf8"), + ); + const child = NodeChildProcess.spawnSync( + process.execPath, + [NodePath.join(import.meta.dirname, "bin.ts"), "--version"], + { encoding: "utf8" }, + ); + assert.equal(child.status, 0); + assert.equal(child.stdout, `t3 v${packageJson.version}\n`); + assert.equal(child.stderr, ""); +}); diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 5a41e5fd08e26..6a27f7fe31311 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -1,3 +1,4 @@ +import * as ProjectLayers from "./projectLayers.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -103,8 +104,8 @@ const layerTestProviderInstanceRegistry = Layer.succeed( }, ); -const layerTest = Layer.mergeAll(RuntimeLayer.layer, RuntimeLayer.layerEventSink).pipe( - Layer.provideMerge(RuntimeLayer.layerProjectService), +const layerTest = Layer.mergeAll(RuntimeLayer.layer, ProjectLayers.layerEventSink).pipe( + Layer.provideMerge(ProjectLayers.layerProjectService), Layer.provide( Layer.mock(WorkspacePaths.WorkspacePaths)({ normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot), diff --git a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts index c5eeeee6698e8..1cd8a25adc8c6 100644 --- a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts +++ b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts @@ -1,3 +1,4 @@ +import * as ProjectLayers from "./projectLayers.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { assert, it } from "@effect/vitest"; @@ -137,8 +138,8 @@ const layerRuntime = (dbPath: string) => { ), ); return Layer.mergeAll( - RuntimeLayer.layer.pipe(Layer.provide(RuntimeLayer.layerProjectService)), - RuntimeLayer.layerProjectService, + RuntimeLayer.layer.pipe(Layer.provide(ProjectLayers.layerProjectService)), + ProjectLayers.layerProjectService, ).pipe( Layer.provide( Layer.mock(ProjectEnrichmentService.ProjectEnrichmentService)({ diff --git a/apps/server/src/orchestration-v2/projectLayers.ts b/apps/server/src/orchestration-v2/projectLayers.ts new file mode 100644 index 0000000000000..176737a9fd4d8 --- /dev/null +++ b/apps/server/src/orchestration-v2/projectLayers.ts @@ -0,0 +1,53 @@ +import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; +import * as EventSink from "./EventSink.ts"; +import * as EventStore from "./EventStore.ts"; +import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; +import * as Layer from "effect/Layer"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; + +/** The shared application event log and its command receipts. */ +export const layerEventInfrastructure = Layer.mergeAll( + OrchestrationEventStore.layer, + OrchestrationCommandReceipts.layer, +); + +const layerEventStoreProvided = EventStore.layerFromOrchestrationEventStore.pipe( + Layer.provide(layerEventInfrastructure), +); +export const layerCommandReceiptStoreProvided = + CommandReceiptStore.layerFromApplicationReceipts.pipe(Layer.provide(layerEventInfrastructure)); + +export const layerStores = Layer.mergeAll( + layerEventInfrastructure, + layerEventStoreProvided, + ProjectionStore.layer, + ProjectStore.layer, + layerCommandReceiptStoreProvided, + EffectOutbox.layer, + TurnItemPositionStore.layer, +); + +export const layerEventSink = EventSink.layerFromStores.pipe(Layer.provide(layerStores)); +const layerEventSinkProvided = layerEventSink; +export const layerLegacyV1ThreadImporterProvided = LegacyV1ThreadImporter.layer.pipe( + Layer.provide(layerEventSinkProvided), +); + +export const layerProjectService = ProjectService.layer.pipe( + Layer.provide( + Layer.mergeAll( + ProjectStore.layer, + ProjectionStore.layer, + layerEventSinkProvided, + IdAllocator.layer, + layerLegacyV1ThreadImporterProvided, + ), + ), +); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 3d973ddae8260..35e643336f795 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1,3 +1,4 @@ +import * as ProjectLayers from "./projectLayers.ts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -266,7 +267,7 @@ const moveProject = (projectId: ProjectId, workspaceRoot: string, updatedAt: str const layerTest = Layer.mergeAll( RuntimeLayer.layer, - RuntimeLayer.layerEventSink, + ProjectLayers.layerEventSink, ProjectStore.layer, ProjectionStore.layer, EffectOutbox.layer, @@ -298,9 +299,9 @@ const layerLegacyImportTest = RuntimeLayer.layer.pipe( ); const layerProjectDeletionTest = Layer.mergeAll( - RuntimeLayer.layer.pipe(Layer.provide(RuntimeLayer.layerProjectService)), - RuntimeLayer.layerProjectService, - RuntimeLayer.layerEventSink, + RuntimeLayer.layer.pipe(Layer.provide(ProjectLayers.layerProjectService)), + ProjectLayers.layerProjectService, + ProjectLayers.layerEventSink, ThreadCommandExecutor.layer, ).pipe( Layer.provide( @@ -441,10 +442,10 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i }); const layerSharedApplicationDataPlaneTest = Layer.mergeAll( - RuntimeLayer.layer.pipe(Layer.provide(RuntimeLayer.layerProjectService)), - RuntimeLayer.layerProjectService, - RuntimeLayer.layerEventSink, - RuntimeLayer.layerEventInfrastructure, + RuntimeLayer.layer.pipe(Layer.provide(ProjectLayers.layerProjectService)), + ProjectLayers.layerProjectService, + ProjectLayers.layerEventSink, + ProjectLayers.layerEventInfrastructure, ).pipe( Layer.provide( Layer.mock(WorkspacePaths.WorkspacePaths)({ diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 2442a4fa010c8..575e38a96c14c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -1,8 +1,14 @@ +import { + layerEventInfrastructure, + layerEventSink, + layerProjectService, + layerStores, + layerCommandReceiptStoreProvided, + layerLegacyV1ThreadImporterProvided, +} from "./projectLayers.ts"; import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as Layer from "effect/Layer"; -import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts"; -import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts"; import * as McpAppRequests from "../mcpApps/McpAppRequests.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; @@ -10,21 +16,16 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as AgentSessionImporter from "../project/AgentSessionImporter.ts"; import * as AgentSessionScanner from "../project/AgentSessionScanner.ts"; -import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as CheckpointCaptureService from "./CheckpointCaptureService.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; import * as CommandPolicy from "./CommandPolicy.ts"; -import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EffectWorker from "./EffectWorker.ts"; -import * as EventSink from "./EventSink.ts"; -import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; @@ -48,57 +49,17 @@ import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ThreadLaunchService from "./ThreadLaunchService.ts"; import * as ThreadLifecycleService from "./ThreadLifecycleService.ts"; import * as ThreadForkService from "./ThreadForkService.ts"; -import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; -/** The shared application event log and its command receipts. */ -export const layerEventInfrastructure = Layer.mergeAll( - OrchestrationEventStore.layer, - OrchestrationCommandReceipts.layer, -); - const layerRuntimePolicyProvided = RuntimePolicy.layerFromProjectStore.pipe( Layer.provide(ProjectStore.layer), ); -const layerEventStoreProvided = EventStore.layerFromOrchestrationEventStore.pipe( - Layer.provide(layerEventInfrastructure), -); -const layerCommandReceiptStoreProvided = CommandReceiptStore.layerFromApplicationReceipts.pipe( - Layer.provide(layerEventInfrastructure), -); - -const layerStores = Layer.mergeAll( - layerEventInfrastructure, - layerEventStoreProvided, - ProjectionStore.layer, - ProjectStore.layer, - layerCommandReceiptStoreProvided, - EffectOutbox.layer, - TurnItemPositionStore.layer, -); - -export const layerEventSink = EventSink.layerFromStores.pipe(Layer.provide(layerStores)); const layerEventSinkProvided = layerEventSink; const layerProjectionMaintenanceProvided = ProjectionMaintenance.layer.pipe( Layer.provide(layerStores), ); -const layerLegacyV1ThreadImporterProvided = LegacyV1ThreadImporter.layer.pipe( - Layer.provide(layerEventSinkProvided), -); - -export const layerProjectService = ProjectService.layer.pipe( - Layer.provide( - Layer.mergeAll( - ProjectStore.layer, - ProjectionStore.layer, - layerEventSinkProvided, - IdAllocator.layer, - layerLegacyV1ThreadImporterProvided, - ), - ), -); const layerProviderEventIngestorProvided = ProviderEventIngestor.layer.pipe( Layer.provide( diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index cd490079f2cc7..ee4919714c249 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -1,3 +1,4 @@ +import * as ProjectLayers from "../orchestration-v2/projectLayers.ts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, GitCommandError, ProjectId, ThreadId } from "@t3tools/contracts"; @@ -12,7 +13,6 @@ import * as Scope from "effect/Scope"; import * as ServerConfig from "../config.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; -import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -78,7 +78,7 @@ const layer = (baseDir: string, options?: HarnessOptions) => ProjectService.ProjectService.pipe(Effect.map(options.projects)), ), ), - Layer.provideMerge(RuntimeLayer.layerProjectService), + Layer.provideMerge(ProjectLayers.layerProjectService), Layer.provideMerge(layerEnrichment), Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(layerGitWorkflow), diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 09d4f82297718..e89e55798f437 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -1,3 +1,4 @@ +import * as ProjectLayers from "../orchestration-v2/projectLayers.ts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, type Project, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; @@ -16,7 +17,6 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; -import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -55,7 +55,7 @@ const layerTestFor = ( | RepositoryIdentityResolver.RepositoryIdentityResolver >, ) => - RuntimeLayer.layerProjectService.pipe( + ProjectLayers.layerProjectService.pipe( Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(layerWorkspacePaths), Layer.provideMerge(projectMetadataLayer), @@ -68,13 +68,15 @@ const layerTest = layerTestFor(layerMetadata); /** Every dependency of ProjectService.make, so a test can swap one of them. */ const layerProjectServiceDependencies = Layer.mergeAll( - RuntimeLayer.layerEventSink, + ProjectLayers.layerEventSink, ProjectStore.layer, ProjectionStore.layer, IdAllocator.layer, ThreadCommandExecutor.layer, ).pipe( - Layer.provideMerge(LegacyV1ThreadImporter.layer.pipe(Layer.provide(RuntimeLayer.layerEventSink))), + Layer.provideMerge( + LegacyV1ThreadImporter.layer.pipe(Layer.provide(ProjectLayers.layerEventSink)), + ), Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(layerWorkspacePaths), Layer.provideMerge(layerMetadata), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index cd8847071100e..6e10fed844128 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,3 +1,4 @@ +import * as ProjectLayers from "./orchestration-v2/projectLayers.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; import * as Clock from "effect/Clock"; @@ -181,7 +182,6 @@ import * as OrchestrationHttp from "./orchestration-v2/http.ts"; import * as ProjectHttp from "./project/http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; import * as ServerActivation from "./serverActivation.ts"; // MCP handoff thread IDs include escaped provenance and can exceed find-my-way's @@ -501,7 +501,7 @@ const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( RunFinalizationService.layerObserver.pipe( Layer.provide(ProjectionStoreV2.layer), Layer.provide(layerPullRequestService), - Layer.provide(RuntimeLayer.layerProjectService), + Layer.provide(ProjectLayers.layerProjectService), ), ), ); @@ -589,7 +589,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ).pipe( // Core Services Layer.provideMerge(layerOrchestrationApplication), - Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), + Layer.provideMerge(ProjectLayers.layerEventInfrastructure), Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), Layer.provideMerge(layerServerSettings), // The asset route uses the registry's GitHub credential for private PR media, which the @@ -815,6 +815,9 @@ const layerMakeServer = Layer.unwrap( } const localPort = address.port; + const { ensureTailscaleServe } = yield* Effect.promise( + () => import("@t3tools/tailscale"), + ); return yield* ensureTailscaleServe({ localPort, servePort: config.tailscaleServePort, @@ -838,19 +841,25 @@ const layerMakeServer = Layer.unwrap( }), (configured) => configured - ? disableTailscaleServe({ servePort: configured.servePort }).pipe( - Effect.tap(() => - Effect.logInfo("Tailscale Serve disabled", { - servePort: configured.servePort, - }), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to disable Tailscale Serve", { - cause, - servePort: configured.servePort, - }), - ), - ) + ? Effect.promise(() => import("@t3tools/tailscale")) + .pipe( + Effect.flatMap(({ disableTailscaleServe }) => + disableTailscaleServe({ servePort: configured.servePort }), + ), + ) + .pipe( + Effect.tap(() => + Effect.logInfo("Tailscale Serve disabled", { + servePort: configured.servePort, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to disable Tailscale Serve", { + cause, + servePort: configured.servePort, + }), + ), + ) : Effect.void, ), ) diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index bcb0208d9c867..1c14b35031093 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -1,4 +1,6 @@ import "vite-plus/test/config"; +import * as Effect from "effect/Effect"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; @@ -34,12 +36,15 @@ const packExecutable = process.env.T3CODE_PACK_EXE === "1"; // `-` in nodejs.org naming (darwin-x64, linux-arm64, win-x64). // When set, tsdown injects the bundle into a downloaded Node of that target // instead of the host Node, which is how the arm64 macOS runner produces the -// x64 archive. Cross-building is safe because the code cache is off. +// x64 archive. Cross-building disables the architecture-specific code cache. // // The Node inside the executable is pinned here rather than taken from the // build host, so every archive of a release embeds the same runtime no matter // which Node happens to run the build. const SEA_NODE_VERSION = "26.8.2"; +const [hostPlatform, hostArch] = Effect.runSync( + Effect.all([HostProcessPlatform, HostProcessArchitecture]), +); const SEA_TARGETS = { "darwin-arm64": { platform: "darwin", arch: "arm64" }, "darwin-x64": { platform: "darwin", arch: "x64" }, @@ -88,11 +93,16 @@ export default mergeConfig( fileName: "t3", outDir: "dist-exe", ...(packExecutableTargets ? { targets: packExecutableTargets } : {}), - // Node's SEA docs: `import()` does not work when useCodeCache is - // true, and the server reaches several modules that way. The - // cache is also platform-bound, so leaving it off keeps the - // build correct on any host. - seaConfig: { useCodeCache: false }, + // Node 26's module SEA supports dynamic builtin imports with code cache. + // Release archives build natively; retain uncached cross-build support. + seaConfig: { + useCodeCache: + !packExecutableTarget || + (SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS].platform === + (hostPlatform === "win32" ? "win" : hostPlatform) && + SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS].arch === + hostArch), + }, }, } : {}), From 0fcf4a84c23ec5d7ad65e0877379335d8dda0875 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 12:59:09 +0000 Subject: [PATCH 006/241] fix(server): executable build config and managed provider test read the HostProcess module --- apps/server/vite.config.ts | 4 ++-- packages/provider-core/src/server/managedProvider.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 1c14b35031093..5307da6622793 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -1,6 +1,6 @@ import "vite-plus/test/config"; import * as Effect from "effect/Effect"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; @@ -43,7 +43,7 @@ const packExecutable = process.env.T3CODE_PACK_EXE === "1"; // which Node happens to run the build. const SEA_NODE_VERSION = "26.8.2"; const [hostPlatform, hostArch] = Effect.runSync( - Effect.all([HostProcessPlatform, HostProcessArchitecture]), + Effect.all([HostProcess.Platform, HostProcess.Architecture]), ); const SEA_TARGETS = { "darwin-arm64": { platform: "darwin", arch: "arm64" }, diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index e2b73c894dc86..18bc5dc8ddc02 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -1,5 +1,5 @@ import * as Exit from "effect/Exit"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ProviderAdapter from "./ProviderAdapter.ts"; import { makeLazyAdapter } from "./lazyAdapter.ts"; import { describe, it, assert } from "@effect/vitest"; @@ -707,7 +707,7 @@ it.effect( driver, Effect.gen(function* () { constructed++; - owner = (yield* HostProcessEnvironment).OWNER; + owner = (yield* HostProcess.Environment).OWNER; yield* Effect.addFinalizer(() => Effect.sync(() => { finalized++; @@ -741,7 +741,7 @@ it.effect( ], { concurrency: "unbounded" }, ).pipe( - Effect.provideService(HostProcessEnvironment, { OWNER: "request" }), + Effect.provideService(HostProcess.Environment, { OWNER: "request" }), Effect.forkScoped, ); yield* Deferred.await(started); @@ -754,7 +754,7 @@ it.effect( yield* adapter.getCapabilities().pipe(Effect.scoped, Effect.exit); assert.equal(constructed, 1); assert.equal(finalized, 0); - }).pipe(Effect.provideService(HostProcessEnvironment, { OWNER: "instance" })), + }).pipe(Effect.provideService(HostProcess.Environment, { OWNER: "instance" })), ); assert.equal(finalized, 1); }), From 8b1537a6143860e3040e3fdeb66d5d0477ecf15c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 12:59:09 +0000 Subject: [PATCH 007/241] fix(cli): the spawn host does not enable the compile cache --- apps/server/src/bin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 006d2626609bb..10e0e588c2c65 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -21,7 +21,8 @@ if ( }) ) { const command = process.argv[2]; - if (!(process.argv.length === 3 && command === "--version")) { + // The spawn host loads only Node builtins and has no --base-dir to place a cache under. + if (!(process.argv.length === 3 && command === "--version") && command !== "__spawn-host") { enableCliCompileCache({ bundled: NodeSea.isSea() || import.meta.url.endsWith(".mjs"), argv: process.argv.slice(2), From 8edc94879cd17210d4b50d4f6d06eca7318dc7e8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 07:20:05 +0000 Subject: [PATCH 008/241] fix(server): retain stopped threads with idle shared provider sessions --- apps/server/scripts/migrate-dev-db.test.ts | 2 +- apps/server/scripts/migrate-dev-db.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 07d3dc61c7913..de95e494aa463 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -73,7 +73,7 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio // A provider session shared by two threads names its latest writer. yield* sql`INSERT INTO orchestration_v2_projection_provider_sessions (provider_session_id, thread_id, provider, status, updated_at, payload_json) - VALUES ('session-shared', 'running-thread', 'codex', 'stopped', '2026-08-01', '{}')`; + VALUES ('session-shared', 'running-thread', 'codex', 'ready', '2026-08-01', '{"status":"ready"}')`; yield* sql`INSERT INTO orchestration_v2_projection_provider_session_bindings (provider_session_id, thread_id) VALUES ('session-shared', 'running-thread'), ('session-shared', 'stopped-thread')`; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 4cfdf62333460..088d73edc9b3d 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -330,6 +330,14 @@ const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { const sql = yield* SqlClient.SqlClient; + // A ready session is an idle reusable provider handle, not executing work. + // Old installs bind that handle to many completed threads. Stop only those + // idle handles in the copy before asking the runtime recovery queries which + // threads could resume work; active runs/provider threads still exclude them. + yield* sql`UPDATE orchestration_v2_projection_provider_sessions + SET status = 'stopped', payload_json = json_set(payload_json, '$.status', 'stopped') + WHERE status = 'ready'`; + // Threads the server would resume or start work on by itself. Its own // recovery queries find most of them; usage-limit recovery also depends on // settings, so any thread whose latest run failed or that has a recovery From 069b224dfd4d482c8e1d3c6af01dca62bc28b58e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 07:20:12 +0000 Subject: [PATCH 009/241] fix(server): report short event loop stalls from startup --- .../observability/EventLoopMonitor.test.ts | 33 ++++++------ .../src/observability/EventLoopMonitor.ts | 54 +++++++++++-------- 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/apps/server/src/observability/EventLoopMonitor.test.ts b/apps/server/src/observability/EventLoopMonitor.test.ts index 1b9fd72049b98..13c15cfa29f33 100644 --- a/apps/server/src/observability/EventLoopMonitor.test.ts +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -8,7 +8,7 @@ import * as EventLoopMonitor from "./EventLoopMonitor.ts"; const ms = (value: number) => value * 1e6; -// Node reports a stall of S as a gap of up to S + 1 s, the histogram resolution. +// Node reports a stall of S as a gap of up to S + 100 ms, the histogram resolution. const stalled: EventLoopMonitor.EventLoopReadings = { delayMaxNs: ms(5_950), activeMs: 6_200, @@ -23,7 +23,7 @@ const stalled: EventLoopMonitor.EventLoopReadings = { rssBytes: 1536 * 1024 * 1024, }; // Over the threshold as read, but not once the resolution is subtracted. -const quiet: EventLoopMonitor.EventLoopReadings = { ...stalled, delayMaxNs: ms(2_950) }; +const quiet: EventLoopMonitor.EventLoopReadings = { ...stalled, delayMaxNs: ms(300) }; describe("EventLoopMonitor", () => { it.effect("records a warning span only for samples that saw a stall", () => @@ -36,25 +36,26 @@ describe("EventLoopMonitor", () => { return span; }, }); - // The first sample covers startup, so the monitor discards it. const samples = [stalled, quiet, stalled]; yield* Effect.gen(function* () { yield* Layer.build( EventLoopMonitor.layerWith(Effect.succeed(Effect.sync(() => samples.shift() ?? quiet))), ); - yield* TestClock.adjust("60 seconds"); - assert.lengthOf(spans, 0); - yield* TestClock.adjust("30 seconds"); + yield* TestClock.adjust("1 second"); + assert.lengthOf(spans, 1); + yield* TestClock.adjust("1 second"); + assert.lengthOf(spans, 1); + yield* TestClock.adjust("1 second"); }).pipe(Effect.scoped, Effect.withTracer(tracer)); assert.deepStrictEqual( spans.map((span) => span.name), - ["server.eventLoop.stall"], + ["server.eventLoop.stall", "server.eventLoop.stall"], ); const [span] = spans; assert.deepStrictEqual(Object.fromEntries(span!.attributes), { - delayMaxMs: 4_950, + delayMaxMs: 5_850, utilization: 0.18, cpuUserMs: 310, cpuSystemMs: 96, @@ -65,19 +66,19 @@ describe("EventLoopMonitor", () => { }); assert.deepStrictEqual( span!.events.map(([name, , attributes]) => [name, attributes["effect.logLevel"]]), - [["event loop stalled for 4950 ms", "WARN"]], + [["event loop stalled for 5850 ms", "WARN"]], ); }), ); - it("ignores delay the loop spent idle, such as a system sleep", () => { - // Waking from sleep reads as a long gap, but the loop was idle in poll for it. - const asleep: EventLoopMonitor.EventLoopReadings = { + it("reports short stalls even when ELU active time lags the delay reading", () => { + const short: EventLoopMonitor.EventLoopReadings = { ...stalled, - delayMaxNs: ms(600_000), - activeMs: 900, + delayMaxNs: ms(850), + activeMs: 100, }; - assert.isUndefined(EventLoopMonitor.stallMs(asleep)); - assert.strictEqual(EventLoopMonitor.stallMs({ ...asleep, activeMs: 600_000 }), 599_000); + assert.strictEqual(EventLoopMonitor.stallMs(short), 750); + assert.strictEqual(EventLoopMonitor.stallMs({ ...quiet, tickDelayMs: 53_000 }), 53_000); + assert.isUndefined(EventLoopMonitor.stallMs(quiet)); }); }); diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts index 13b7b48c4cf79..e5bc8c36087fc 100644 --- a/apps/server/src/observability/EventLoopMonitor.ts +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -8,15 +8,17 @@ import type * as Scope from "effect/Scope"; // Node's delay histogram wakes a native timer every RESOLUTION_MS and records the // gap between wakeups, so an idle loop reads about RESOLUTION_MS and a stall of S // reads between S and S + RESOLUTION_MS. We subtract the resolution, so a delay can -// undercount a stall by up to RESOLUTION_MS. With these values every stall over 3 s -// is caught, at 1 wakeup per second that never enters JS. -const RESOLUTION_MS = 1000; -const STALL_THRESHOLD_MS = 2000; -const SAMPLE_INTERVAL = "30 seconds"; +// undercount a stall by up to RESOLUTION_MS. Ten native wakeups per second catch +// short stalls without scheduling JS work at that frequency. +const RESOLUTION_MS = 100; +const STALL_THRESHOLD_MS = 250; +const SAMPLE_INTERVAL_MS = 1000; /** One sample interval as Node reports it. Delay in ns, active time in ms, CPU in µs. */ export interface EventLoopReadings { readonly delayMaxNs: number; + /** Timer drift also covers startup and the histogram's first gap after reset. */ + readonly tickDelayMs?: number; readonly activeMs: number; readonly utilization: number; readonly usage: Pick< @@ -44,14 +46,26 @@ const makeNodeSampler = Effect.gen(function* () { ); let elu = NodePerfHooks.performance.eventLoopUtilization(); let usage = process.resourceUsage(); + let sampledAt = NodePerfHooks.performance.now(); + // The native histogram needs one wakeup before it can record a gap. One + // startup probe covers a stall that finishes before the first one-second tick. + const armedAt = sampledAt; + let startupDelayMs = 0; + // @effect-diagnostics-next-line globalTimersInEffect:off -- The native boot probe must arm before Effect scheduling can run startup work. + const startupProbe = setTimeout(() => { + startupDelayMs = Math.max(0, NodePerfHooks.performance.now() - armedAt - RESOLUTION_MS); + }, RESOLUTION_MS); + yield* Effect.addFinalizer(() => Effect.sync(() => clearTimeout(startupProbe))); // @effect-diagnostics-next-line returnEffectInGen:off - the read effect is the result. return Effect.sync(() => { const nextElu = NodePerfHooks.performance.eventLoopUtilization(); const nextUsage = process.resourceUsage(); + const now = NodePerfHooks.performance.now(); const loop = NodePerfHooks.performance.eventLoopUtilization(nextElu, elu); const readings: EventLoopReadings = { delayMaxNs: histogram.max, + tickDelayMs: Math.max(startupDelayMs, now - sampledAt - SAMPLE_INTERVAL_MS, 0), activeMs: loop.active, utilization: loop.utilization, usage: { @@ -65,8 +79,10 @@ const makeNodeSampler = Effect.gen(function* () { rssBytes: process.memoryUsage.rss(), }; histogram.reset(); + startupDelayMs = 0; elu = nextElu; usage = nextUsage; + sampledAt = now; return readings; }); }); @@ -74,18 +90,17 @@ const makeNodeSampler = Effect.gen(function* () { /** * Returns the stall to report for one sample in ms, or undefined when there was none. */ -export const stallMs = ({ delayMaxNs, activeMs }: EventLoopReadings) => { - const delayMs = Math.round(delayMaxNs / 1e6) - RESOLUTION_MS; - // A stall is time the loop spent running code, so it counts as active time. libuv's - // clock keeps running while the system sleeps on macOS and Windows, so a sleep also - // reads as delay, but the loop spent it idle in poll. - if (delayMs <= STALL_THRESHOLD_MS || activeMs < delayMs) return undefined; +export const stallMs = ({ delayMaxNs, tickDelayMs = 0 }: EventLoopReadings) => { + const delayMs = Math.round(Math.max(delayMaxNs / 1e6 - RESOLUTION_MS, tickDelayMs)); + // ELU's active time can lag the histogram, especially across startup and resets. + // Keep it as diagnostic context rather than letting it suppress an observed gap. + if (delayMs <= STALL_THRESHOLD_MS) return undefined; return delayMs; }; /** - * Samples event loop health every 30 s and records a `server.eventLoop.stall` span - * with a warning when the loop stalled for more than 2 s, so stalls land in + * Samples event loop health every second and records a `server.eventLoop.stall` span + * with a warning when the loop stalled for more than 250 ms, so stalls land in * the local trace file and Settings > Diagnostics without OTLP. Takes the sampler * so tests can inject readings. */ @@ -119,16 +134,9 @@ export const layerWith = ( }), ); }); - const wait = Effect.sleep(SAMPLE_INTERVAL); - // The layer builds before the rest of the server, so the first sample covers - // startup work such as migrations and projection bootstrap. That can block the - // loop for seconds on a large database, so skip it rather than warn at every - // launch. Layers build outside any span, so this fiber retains no parent span. - yield* wait.pipe( - Effect.andThen(sample), - Effect.andThen(wait.pipe(Effect.andThen(tick), Effect.forever)), - Effect.forkScoped, - ); + const wait = Effect.sleep(SAMPLE_INTERVAL_MS); + // Include the first sample: startup stalls are particularly costly on a large DB. + yield* wait.pipe(Effect.andThen(tick), Effect.forever, Effect.forkScoped); }), ); From 6fb73eaf5e325042a0c3fc7b1e05ad0335b9e19b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 07:20:23 +0000 Subject: [PATCH 010/241] perf(server): compact database reads and verified maintenance --- apps/server/src/binCli.ts | 2 + apps/server/src/cli/db.ts | 30 ++ .../server/src/cloud/servicePreflight.test.ts | 39 ++ apps/server/src/cloud/servicePreflight.ts | 26 +- .../src/orchestration-v2/Orchestrator.ts | 5 +- .../orchestration-v2/ProjectionMaintenance.ts | 1 + .../orchestration-v2/ProjectionStore.test.ts | 105 ++++- .../src/orchestration-v2/ProjectionStore.ts | 98 ++++- .../ThreadManagementService.ts | 1 + .../ThreadPullRequestService.test.ts | 18 +- .../ThreadPullRequestService.ts | 5 +- .../src/orchestration-v2/ThreadReadPayload.ts | 110 +++++ apps/server/src/orchestration-v2/http.ts | 6 +- .../src/persistence/DatabaseCompaction.ts | 387 ++++++++++++++++++ apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/055_OrchestrationV2.test.ts | 13 +- .../062_ProjectionThreadReadPayload.ts | 39 ++ apps/server/src/serviceDatabaseBackup.ts | 63 +++ apps/server/src/serviceLauncher.test.ts | 107 +++++ apps/server/src/serviceLauncher.ts | 80 +++- apps/server/src/ws.ts | 10 +- .../pullRequest/ThreadPullRequestsPanel.tsx | 11 +- docs/user/updating.md | 9 + packages/client-runtime/src/state/shell.ts | 6 +- .../src/state/shellSnapshotHttp.ts | 3 +- packages/contracts/src/environmentHttp.ts | 1 + packages/contracts/src/orchestrationV2.ts | 2 + 27 files changed, 1136 insertions(+), 43 deletions(-) create mode 100644 apps/server/src/cli/db.ts create mode 100644 apps/server/src/orchestration-v2/ThreadReadPayload.ts create mode 100644 apps/server/src/persistence/DatabaseCompaction.ts create mode 100644 apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts create mode 100644 apps/server/src/serviceDatabaseBackup.ts diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index 76035ded632b6..cd63b92f786cc 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -27,6 +27,7 @@ import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { themeCommand } from "./cli/theme.ts"; import { traceCommand } from "./cli/trace.ts"; import { triageCommand } from "./cli/triage.ts"; +import { dbCommand } from "./cli/db.ts"; const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -85,6 +86,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => themeCommand, traceCommand, triageCommand, + dbCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), ); diff --git a/apps/server/src/cli/db.ts b/apps/server/src/cli/db.ts new file mode 100644 index 0000000000000..48e00fd696ec8 --- /dev/null +++ b/apps/server/src/cli/db.ts @@ -0,0 +1,30 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { Command } from "effect/cli"; +import { resolveBaseDir } from "../os-jank.ts"; +import * as DatabaseCompaction from "../persistence/DatabaseCompaction.ts"; +import { baseDirFlag } from "./config.ts"; + +const compactCommand = Command.make("compact", { baseDir: baseDirFlag }, ({ baseDir }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* resolveBaseDir(Option.getOrUndefined(baseDir)); + const database = yield* DatabaseCompaction.DatabaseCompaction; + const console = yield* Console.Console; + const result = yield* database.compact({ + databasePath: path.join(home, "userdata", "statev2.sqlite"), + onProgress: (message) => console.log(message), + }); + yield* Console.log( + `Verified ${result.verifiedTables.length} projection tables; rewrote ${result.rewrittenEvents} events and pruned ${result.prunedReceipts} imported legacy receipts.`, + ); + }).pipe(Effect.provide(DatabaseCompaction.layer)), +).pipe( + Command.withDescription( + "Back up, verify and compact a stopped server's database. Never runs automatically.", + ), +); + +export const dbCommand = Command.make("db").pipe(Command.withSubcommands([compactCommand])); diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts index 1b9cf8e46c41e..859ebbccc1ce5 100644 --- a/apps/server/src/cloud/servicePreflight.test.ts +++ b/apps/server/src/cloud/servicePreflight.test.ts @@ -1,3 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Preflight is a standalone Node launcher path; this test checks its readonly ledger. +import * as NodeSqlite from "node:sqlite"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { migrationManifest } from "../persistence/Migrations.ts"; import { expect, it } from "@effect/vitest"; import { runServicePreflight } from "./servicePreflight.ts"; @@ -29,5 +35,38 @@ it("accepts the current launcher protocol", () => { status: "ready", version: "1.2.3", launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, + needsDatabaseBackup: true, }); }); + +it("skips backup only when the target migration ledger is complete without mutating it", () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-preflight-")); + const databasePath = NodePath.join(directory, "state.sqlite"); + const database = new NodeSqlite.DatabaseSync(databasePath); + try { + database.exec( + "CREATE TABLE effect_sql_migrations (migration_id INTEGER PRIMARY KEY, name TEXT)", + ); + const insert = database.prepare("INSERT INTO effect_sql_migrations VALUES (?, ?)"); + for (const [id, name] of migrationManifest) insert.run(id, name); + const preflight = () => + runServicePreflight({ + databasePath, + launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, + version: "1.2.3", + }); + expect(preflight()).toMatchObject({ status: "ready", needsDatabaseBackup: false }); + const latest = migrationManifest.at(-1)!; + database.prepare("DELETE FROM effect_sql_migrations WHERE migration_id=?").run(latest[0]); + const missing = database + .prepare("SELECT * FROM effect_sql_migrations ORDER BY migration_id") + .all(); + expect(preflight()).toMatchObject({ status: "ready", needsDatabaseBackup: true }); + expect( + database.prepare("SELECT * FROM effect_sql_migrations ORDER BY migration_id").all(), + ).toEqual(missing); + } finally { + database.close(); + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts index ee0f972baa3fc..dab31762f87ac 100644 --- a/apps/server/src/cloud/servicePreflight.ts +++ b/apps/server/src/cloud/servicePreflight.ts @@ -1,4 +1,6 @@ import packageJson from "../../package.json" with { type: "json" }; +import * as NodeSqlite from "node:sqlite"; +import { migrationManifest } from "../persistence/Migrations.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; export type ServicePreflightResult = @@ -6,6 +8,7 @@ export type ServicePreflightResult = readonly status: "ready"; readonly version: string; readonly launcherProtocol: typeof SERVICE_LAUNCHER_PROTOCOL; + readonly needsDatabaseBackup?: boolean; } | { readonly status: "blocked"; @@ -29,7 +32,25 @@ export function runServicePreflight(input: { }; } - return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }; + let needsDatabaseBackup = true; + try { + const database = new NodeSqlite.DatabaseSync(input.databasePath, { readOnly: true }); + try { + const rows = database.prepare("SELECT migration_id, name FROM effect_sql_migrations").all(); + const recorded = new Map(rows.map((row) => [row.migration_id, row.name])); + needsDatabaseBackup = migrationManifest.some(([id, name]) => recorded.get(id) !== name); + } finally { + database.close(); + } + } catch { + // Missing/unreadable ledgers must take the conservative backup path. + } + return { + status: "ready", + version, + launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, + needsDatabaseBackup, + }; } export function decodeServicePreflightResult(value: unknown): ServicePreflightResult | undefined { @@ -46,6 +67,9 @@ export function decodeServicePreflightResult(value: unknown): ServicePreflightRe status: "ready", version: record.version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, + ...(typeof record.needsDatabaseBackup === "boolean" + ? { needsDatabaseBackup: record.needsDatabaseBackup } + : {}), }; } if ( diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index beae11b1f06c6..30a6102aaf346 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -349,6 +349,7 @@ export interface OrchestratorV2Shape { >; readonly getThreadShell: ( threadId: ThreadId, + options?: ShellSnapshotOptions, ) => Effect.Effect; readonly getThreadEventSequence: ( threadId: ThreadId, @@ -10932,9 +10933,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.mapError(shellProjectionError), Effect.map(Effect.mapError(shellProjectionError)), ), - getThreadShell: (threadId) => + getThreadShell: (threadId, options) => projectionStore - .getThreadShell(threadId) + .getThreadShell(threadId, options) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), getThreadEventSequence: (threadId) => eventSink diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index 8c10de3142028..8df77f7c7394c 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -139,6 +139,7 @@ export const layer: Layer.Layer< yield* sql`DELETE FROM orchestration_v2_projection_nodes`; yield* sql`DELETE FROM orchestration_v2_projection_run_attempts`; yield* sql`DELETE FROM orchestration_v2_projection_runs`; + yield* sql`DELETE FROM orchestration_v2_projection_thread_reads`; yield* sql`DELETE FROM orchestration_v2_projection_threads`; yield* sql`DELETE FROM orchestration_v2_turn_item_positions`; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 15bef6433d798..64d5436dd349c 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -30,12 +30,23 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; +import { + resolveThreadPullRequestBadge, + resolveThreadPullRequestChains, + threadPullRequestSearchTerms, +} from "@t3tools/shared/threadPullRequests"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; import ProjectionThreadSweepIndexes from "../persistence/Migrations/061_ProjectionThreadSweepIndexes.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import { + compactThreadPullRequests, + decodeThreadReadPayload, + encodeThreadReadPayload, +} from "./ThreadReadPayload.ts"; +import { compactThreadFieldEvent } from "../persistence/DatabaseCompaction.ts"; import { buildBoundedThreadProjection, decodeThreadHistoryCursor, @@ -1868,11 +1879,11 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { // No decoding under the read transaction: a payload that cannot decode // fails the returned step, not the read. const [stored] = yield* sql<{ readonly payload_json: string }>` - SELECT payload_json FROM orchestration_v2_projection_threads + SELECT payload_json FROM orchestration_v2_projection_thread_reads WHERE thread_id = ${laterThreadId} `; const setPayload = (payload: string) => - sql`UPDATE orchestration_v2_projection_threads SET payload_json = ${payload} + sql`UPDATE orchestration_v2_projection_thread_reads SET payload_json = ${payload} WHERE thread_id = ${laterThreadId}`; yield* setPayload("{}"); const failure = yield* sql @@ -1963,6 +1974,28 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const markedUnread = yield* projectionStore.getThreadProjection(threadId); assert.isNull(markedUnread.thread.lastVisitedAt); assert.deepEqual(markedUnread.thread.updatedAt, createdAt); + + const visitedPayload = { ...thread, lastVisitedAt: visitedOccurredAt }; + const legacy = { + id: EventId.make("event:compact-visit"), + type: "thread.visited" as const, + threadId, + occurredAt: visitedOccurredAt, + payload: visitedPayload, + }; + assert.equal(compactThreadFieldEvent(legacy, thread).type, "thread.visit-recorded"); + assert.strictEqual( + compactThreadFieldEvent( + { ...legacy, payload: { ...visitedPayload, title: "Also changed" } }, + thread, + ).type, + "thread.visited", + ); + assert.deepEqual( + yield* decodeThreadReadPayload(yield* encodeThreadReadPayload(thread)), + thread, + ); + assert.deepEqual(compactThreadPullRequests(thread), thread); }), ); @@ -4863,6 +4896,74 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.deepEqual(synced.pullRequests?.[0]?.snapshot, snapshot); assert.isDefined(synced.pullRequests?.[0]?.watch); assert.deepEqual(synced.updatedAt, at); + const previous = { + ...synced, + pullRequests: [{ ...synced.pullRequests![0]!, snapshot: null }], + }; + const legacySync = { + id: EventId.make("event:compact-pr-sync"), + type: "thread.pull-request-synced" as const, + threadId, + occurredAt: at, + payload: synced, + }; + assert.equal( + compactThreadFieldEvent(legacySync, previous).type, + "thread.pull-request-link-synced", + ); + assert.equal( + compactThreadFieldEvent( + { ...legacySync, payload: { ...synced, title: "Changed too" } }, + previous, + ).type, + "thread.pull-request-synced", + ); + const stack = { + kind: "native" as const, + id: "stack:codec", + number: 1, + url: "https://github.com/pingdotgg/t3code/stack/1", + base: "main", + layers: [7, 8, 9].map((number) => ({ + number, + headBranch: `branch-${number}`, + state: "open" as const, + })), + }; + const rich = { + ...synced, + pullRequests: [7, 8, 9].map((number) => ({ + ...synced.pullRequests![0]!, + number, + stack: number === 9 ? { ...stack, base: "older-base" } : stack, + snapshot: { ...snapshot, additions: 23, deletions: 2, checksState: "passing" as const }, + })), + }; + assert.deepEqual(yield* decodeThreadReadPayload(yield* encodeThreadReadPayload(rich)), rich); + const compact = compactThreadPullRequests(rich); + assert.isUndefined(compact.pullRequests?.[0]?.snapshot?.additions); + assert.equal(rich.pullRequests[0]?.snapshot?.additions, 23); + assert.deepEqual( + resolveThreadPullRequestBadge(compact.pullRequests), + resolveThreadPullRequestBadge(rich.pullRequests), + ); + assert.deepEqual( + resolveThreadPullRequestChains(compact.pullRequests ?? []).map((chain) => + chain.layers.map((entry) => entry.number), + ), + resolveThreadPullRequestChains(rich.pullRequests).map((chain) => + chain.layers.map((entry) => entry.number), + ), + ); + assert.deepEqual(threadPullRequestSearchTerms(compact), threadPullRequestSearchTerms(rich)); + yield* syncPullRequests("rich", rich.pullRequests); + assert.deepEqual((yield* store.getThreadShell(threadId))?.pullRequests, rich.pullRequests); + assert.deepEqual( + (yield* store.getThreadShell(threadId, { compactPullRequests: true }))?.pullRequests, + compact.pullRequests, + ); + assert.deepEqual((yield* store.getThread(threadId)).pullRequests, rich.pullRequests); + yield* syncPullRequests("restore", synced.pullRequests ?? []); assert.equal(yield* phase, "running"); assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 925ca4d90e6c5..2386eb5ccfd95 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,4 +1,9 @@ import { projectTurnItemForWire } from "./WireProjection.ts"; +import { + compactThreadPullRequests, + decodeThreadReadPayload, + encodeThreadReadPayload, +} from "./ThreadReadPayload.ts"; import * as Stream from "effect/Stream"; import type { OrchestrationV2SearchThreadInput, @@ -327,6 +332,7 @@ export interface ProjectionTimelinePage { export interface ShellSnapshotOptions { readonly location?: "active" | "archive"; + readonly compactPullRequests?: boolean; /** * For background sweeps, not clients: skips settled threads before any of * their run, item or session rows are read. @@ -389,6 +395,7 @@ export interface ProjectionStoreV2Shape { >; readonly getThreadShell: ( threadId: ThreadId, + options?: ShellSnapshotOptions, ) => Effect.Effect; readonly getThread: ( threadId: ThreadId, @@ -1794,6 +1801,18 @@ export const layer: Layer.Layer = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const upsertThreadRead = (thread: OrchestrationV2AppThread) => + Effect.gen(function* () { + const payload = yield* encodeThreadReadPayload(thread); + const compact = yield* encodeThreadReadPayload(compactThreadPullRequests(thread)); + yield* sql` + INSERT INTO orchestration_v2_projection_thread_reads (thread_id, payload_json, compact_payload_json) + VALUES (${thread.id}, ${payload}, ${compact}) + ON CONFLICT(thread_id) DO UPDATE SET payload_json = excluded.payload_json, + compact_payload_json = excluded.compact_payload_json + `; + }); + // For run upserts: a snapshot without `path` keeps the value another event recorded there. const keepRecordedRunField = (payload: Statement.Fragment, path: string) => sql` CASE @@ -1880,6 +1899,7 @@ export const layer: Layer.Layer = deleted_at = excluded.deleted_at, payload_json = excluded.payload_json `; + yield* upsertThreadRead(event.payload); break; } // These events carry one field rather than the thread, so they patch the stored row. @@ -1907,6 +1927,7 @@ export const layer: Layer.Layer = payload_json = ${payloadJson} WHERE thread_id = ${event.threadId} `; + yield* upsertThreadRead(updatedThread); break; } case "run.created": @@ -2269,6 +2290,7 @@ export const layer: Layer.Layer = payload_json = ${updatedThreadPayloadJson} WHERE thread_id = ${event.payload.appThreadId} `; + yield* upsertThreadRead(updatedThread); } } break; @@ -2711,6 +2733,15 @@ export const layer: Layer.Layer = payload_json = ${payloadJson} WHERE thread_id = ${event.threadId} `; + // Activity-only events change no PR fields. Patch the small read + // projections without encoding their stacks again on every token. + const updatedAt = stringField(parseEncodedPayload(payloadJson), "updatedAt"); + yield* sql` + UPDATE orchestration_v2_projection_thread_reads + SET payload_json = json_set(payload_json, '$.thread.updatedAt', ${updatedAt}), + compact_payload_json = json_set(compact_payload_json, '$.thread.updatedAt', ${updatedAt}) + WHERE thread_id = ${event.threadId} + `; } } }).pipe( @@ -5177,11 +5208,12 @@ export const layer: Layer.Layer = threadId?: ThreadId, location?: "active" | "archive", unsettledOnly = false, + compactPullRequests = false, ) => sql` SELECT t.thread_id, - t.payload_json, + ${compactPullRequests ? sql`summary.compact_payload_json` : sql`summary.payload_json`} AS payload_json, CASE WHEN json_extract(t.payload_json, '$.forkedFrom.type') = 'run' THEN json_extract(t.payload_json, '$.forkedFrom.threadId') @@ -5325,6 +5357,7 @@ export const layer: Layer.Layer = AND i.run_id IS NULL ) AS runless_item_count FROM orchestration_v2_projection_threads t + JOIN orchestration_v2_projection_thread_reads summary ON summary.thread_id = t.thread_id -- The newest run not waiting in a held queue, matching latestUnheldRun. LEFT JOIN orchestration_v2_projection_runs presented ON presented.run_id = ( SELECT candidate.run_id @@ -5495,7 +5528,7 @@ export const layer: Layer.Layer = // provider sessions or inherited fork rows. Reject parked and busy // threads before loading the remaining candidates' background work. const rows = yield* sql` - SELECT t.thread_id, t.payload_json, + SELECT t.thread_id, summary.compact_payload_json AS payload_json, r.run_id AS latest_run_id, r.status AS latest_run_status, r.requested_at AS latest_run_requested_at, @@ -5517,6 +5550,7 @@ export const layer: Layer.Layer = LIMIT 1 ) AS latest_user_authored_message_at FROM orchestration_v2_projection_threads t + JOIN orchestration_v2_projection_thread_reads summary ON summary.thread_id = t.thread_id LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = ( SELECT latest.run_id FROM orchestration_v2_projection_runs latest WHERE latest.thread_id = t.thread_id @@ -5549,7 +5583,7 @@ export const layer: Layer.Layer = }); return yield* Effect.forEach(rows, (row) => Effect.gen(function* () { - const thread = yield* decodeThreadPayload(row.payload_json); + const thread = yield* decodeThreadReadPayload(row.payload_json); const status = shellStatusFromStoredRunStatus(row.latest_run_status); const latestRunId = row.latest_run_id === null ? null : RunId.make(row.latest_run_id); @@ -5608,16 +5642,17 @@ export const layer: Layer.Layer = ) => Effect.gen(function* () { const rows = yield* sql` - SELECT payload_json - FROM orchestration_v2_projection_threads - WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} - AND archived_at IS NULL + SELECT summary.payload_json + FROM orchestration_v2_projection_threads t + JOIN orchestration_v2_projection_thread_reads summary ON summary.thread_id = t.thread_id + WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`} + AND t.archived_at IS NULL -- Read from the sweep index (migration 061) rather than each payload. - AND json_array_length(payload_json, '$.pullRequests') > 0 - ORDER BY updated_at ASC, thread_id ASC + AND json_array_length(t.payload_json, '$.pullRequests') > 0 + ORDER BY t.updated_at ASC, t.thread_id ASC `; return yield* Effect.forEach(rows, (row) => - decodeThreadPayload(row.payload_json).pipe( + decodeThreadReadPayload(row.payload_json).pipe( Effect.map((thread): ProjectionThreadPullRequests => ({ id: thread.id, projectId: thread.projectId, @@ -5651,7 +5686,7 @@ export const layer: Layer.Layer = providerThreadsByThreadId, pendingTurnItemsByThreadId, } = input; - const thread = yield* decodeThreadPayload(row.payload_json); + const thread = yield* decodeThreadReadPayload(row.payload_json); const pendingRuntimeRequest = row.pending_request_payload_json !== null ? yield* decodeRuntimeRequestPayload(row.pending_request_payload_json) @@ -5781,6 +5816,7 @@ export const layer: Layer.Layer = undefined, options?.location, options?.unsettledOnly ?? false, + options?.compactPullRequests ?? false, ); const targetThreadIds = new Set( targetThreadRows.map((row) => ThreadId.make(row.thread_id)), @@ -5796,7 +5832,12 @@ export const layer: Layer.Layer = while (pendingSourceIds.length > 0) { const sourceId = pendingSourceIds.pop(); if (sourceId === undefined || rowsByThreadId.has(sourceId)) continue; - const source = (yield* selectShellThreadRows(sourceId))[0]; + const source = (yield* selectShellThreadRows( + sourceId, + undefined, + false, + options?.compactPullRequests, + ))[0]; if (source === undefined) continue; rowsByThreadId.set(sourceId, source); if (source.forked_from_run_source_thread_id !== null) { @@ -5895,7 +5936,7 @@ export const layer: Layer.Layer = // Per-thread shell for the live shell streams: reads only the target thread // plus its fork-source chain instead of materializing every thread. Returns // null when the thread is deleted or unknown. - const getThreadShell: ProjectionStoreV2Shape["getThreadShell"] = (threadId) => + const getThreadShell: ProjectionStoreV2Shape["getThreadShell"] = (threadId, options) => sql .withTransaction( Effect.gen(function* () { @@ -5906,7 +5947,12 @@ export const layer: Layer.Layer = if (nextId === undefined || rowsByThreadId.has(nextId)) { continue; } - const rows = yield* selectShellThreadRows(nextId); + const rows = yield* selectShellThreadRows( + nextId, + undefined, + false, + options?.compactPullRequests, + ); const row = rows[0]; if (row === undefined) { continue; @@ -6052,7 +6098,17 @@ export const layerMemory: Layer.Layer = Layer.effect( const shells = yield* Effect.forEach( selectedThreadIds.toSorted((left, right) => String(left).localeCompare(String(right))), (threadId) => - service.getThreadProjection(threadId).pipe(Effect.map(threadShellFromProjection)), + service + .getThreadProjection(threadId) + .pipe( + Effect.map((projection) => + threadShellFromProjection( + options?.compactPullRequests + ? { ...projection, thread: compactThreadPullRequests(projection.thread) } + : projection, + ), + ), + ), ); const visible = shells.filter((thread) => thread.deletedAt === null); return { @@ -6064,7 +6120,7 @@ export const layerMemory: Layer.Layer = Layer.effect( }), readShellSnapshot: (options) => service.getShellSnapshot(options).pipe(Effect.map(Effect.succeed)), - getThreadShell: (threadId) => + getThreadShell: (threadId, options) => Effect.gen(function* () { const existing = (yield* Ref.get(replayState)).projections; if (!existing.has(threadId)) { @@ -6072,7 +6128,15 @@ export const layerMemory: Layer.Layer = Layer.effect( } const shell = yield* service .getThreadProjection(threadId) - .pipe(Effect.map(threadShellFromProjection)); + .pipe( + Effect.map((projection) => + threadShellFromProjection( + options?.compactPullRequests + ? { ...projection, thread: compactThreadPullRequests(projection.thread) } + : projection, + ), + ), + ); return shell.deletedAt === null ? shell : null; }), getThread: (threadId) => diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index a12e6680ca7e2..9f49824e48f38 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -322,6 +322,7 @@ export interface ThreadManagementServiceShape { }) => Effect.Effect; readonly readShellSnapshot: (options?: { readonly location?: "active" | "archive"; + readonly compactPullRequests?: boolean; }) => Effect.Effect< Effect.Effect, Orchestrator.OrchestratorV2Error diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 87c9b560f91c8..ab43a62770e83 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -220,7 +220,11 @@ describe("ThreadPullRequestServiceV2 reads", () => { yield* service.start(); yield* Deferred.succeed(activation, undefined); // Startup backfill reads every active thread. - expect(yield* Queue.take(reads)).toEqual({ location: "active", unsettledOnly: false }); + expect(yield* Queue.take(reads)).toEqual({ + location: "active", + compactPullRequests: true, + unsettledOnly: false, + }); yield* service.drain; yield* PubSub.publish(events, { type: "thread.metadata-updated", @@ -309,11 +313,19 @@ describe("ThreadPullRequestServiceV2 reads", () => { yield* service.start(); yield* Deferred.succeed(activation, undefined); // Backfill finds the settled branch thread and must read it again. - expect(yield* Queue.take(reads)).toEqual({ location: "active", unsettledOnly: false }); + expect(yield* Queue.take(reads)).toEqual({ + location: "active", + compactPullRequests: true, + unsettledOnly: false, + }); yield* service.drain; // Its project is gone, so backfill finishes it on the first pass. yield* TestClock.adjust("1 minute"); - expect(yield* Queue.take(reads)).toEqual({ location: "active", unsettledOnly: true }); + expect(yield* Queue.take(reads)).toEqual({ + location: "active", + compactPullRequests: true, + unsettledOnly: true, + }); yield* service.drain; }).pipe(Effect.provide(layerDependencies)); }), diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index e858c10804da9..49adc9a6dfb07 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -129,13 +129,16 @@ export const make = Effect.gen(function* () { threadId === null ? orchestrator.getShellSnapshot({ location: "active", + compactPullRequests: true, unsettledOnly: !(backfill || pendingBackfill.size > 0), }) : Effect.gen(function* () { // Read the sequence first. The thread is then at least this new, so a // sync guarded by the sequence is rejected rather than missing a change. const snapshotSequence = yield* orchestrator.getThreadEventSequence(threadId); - const thread = yield* orchestrator.getThreadShell(threadId); + const thread = yield* orchestrator.getThreadShell(threadId, { + compactPullRequests: true, + }); return { snapshotSequence, threads: thread === null ? [] : [thread] }; }); diff --git a/apps/server/src/orchestration-v2/ThreadReadPayload.ts b/apps/server/src/orchestration-v2/ThreadReadPayload.ts new file mode 100644 index 0000000000000..e093742003a19 --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadReadPayload.ts @@ -0,0 +1,110 @@ +import { + type OrchestrationV2AppThread, + OrchestrationV2AppThreadJson, + ThreadPullRequestStack, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; + +/** Keep shell badges, search and chain order; PR views read canonical snapshots. */ +export const compactThreadPullRequests = ( + thread: OrchestrationV2AppThread, +): OrchestrationV2AppThread => { + const links = thread.pullRequests; + if (links === undefined) return thread; + const stackKey = (link: (typeof links)[number]) => { + const { host, repository } = normalizeThreadPullRequestKey(link); + return JSON.stringify([host, repository, link.stack?.id]); + }; + const members = new Map>(); + for (const link of links) { + if (link.stack === null) continue; + const key = stackKey(link); + const numbers = members.get(key) ?? new Set(); + numbers.add(link.number); + members.set(key, numbers); + } + return { + ...thread, + pullRequests: links.map((link) => ({ + ...link, + snapshot: + link.snapshot === null + ? null + : { + state: link.snapshot.state, + title: link.snapshot.title, + headBranch: link.snapshot.headBranch, + baseBranch: link.snapshot.baseBranch, + isDraft: link.snapshot.isDraft, + updatedAt: link.snapshot.updatedAt, + syncedAt: link.snapshot.syncedAt, + }, + stack: + link.stack === null + ? null + : { + ...link.stack, + layers: link.stack.layers.filter((layer) => + members.get(stackKey(link))?.has(layer.number), + ), + }, + })), + }; +}; + +// Native stacks are repeated on every linked PR. The shell needs their summaries, +// but reading and decoding the same layer list dozens of times dominates large installs. +// This is an internal read projection; the public thread and shell schemas stay unchanged. +const ReadPayload = Schema.fromJsonString( + Schema.Struct({ + thread: OrchestrationV2AppThreadJson, + stacks: Schema.Array(ThreadPullRequestStack), + stackIndices: Schema.Array(Schema.NullOr(Schema.Number)), + }), +); +const encode = Schema.encodeEffect(ReadPayload); +const decode = Schema.decodeUnknownEffect(ReadPayload); + +export const encodeThreadReadPayload = (thread: OrchestrationV2AppThread) => { + const stacks: Array = []; + const indices = new Map(); + const stackIndices: Array = []; + const pullRequests = thread.pullRequests?.map((link) => { + if (link.stack === null) { + stackIndices.push(null); + return link; + } + // Compare the whole stack, not its id: two links can have different sync vintages. + const key = JSON.stringify(link.stack); + let index = indices.get(key); + if (index === undefined) { + index = stacks.length; + stacks.push(link.stack); + indices.set(key, index); + } + stackIndices.push(index); + return { ...link, stack: null }; + }); + return encode({ + thread: { ...thread, ...(pullRequests === undefined ? {} : { pullRequests }) }, + stacks, + stackIndices, + }); +}; + +export const decodeThreadReadPayload = (payload: string) => + decode(payload).pipe( + Effect.map(({ thread, stacks, stackIndices }) => ({ + ...thread, + ...(thread.pullRequests === undefined + ? {} + : { + pullRequests: thread.pullRequests.map((link, index) => ({ + ...link, + stack: stackIndices[index] == null ? null : (stacks[stackIndices[index]!] ?? null), + })), + }), + })), + ); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index d5a924c9d9d99..d206e6de43a30 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -75,11 +75,11 @@ export const layer = HttpApiBuilder.group( ), ); - const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* () { + const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* (compactPullRequests: boolean) { const base = buildActiveShellSnapshot( yield* loadShellSnapshotParts({ sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), + readThreads: threadManagement.readShellSnapshot({ location: "active", compactPullRequests }), listProjects: projectStore.listShells(), latestSequence: applicationEvents.latestApplicationSequence, }), @@ -154,7 +154,7 @@ export const layer = HttpApiBuilder.group( Effect.fn("environment.orchestration.shellSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - return yield* loadShellSnapshot().pipe( + return yield* loadShellSnapshot(args.query.compactPullRequests === "true").pipe( traceLocalHandlerWork, Effect.catch((cause) => failEnvironmentInternal("orchestration_snapshot_failed", cause), diff --git a/apps/server/src/persistence/DatabaseCompaction.ts b/apps/server/src/persistence/DatabaseCompaction.ts new file mode 100644 index 0000000000000..4d7c6a1651107 --- /dev/null +++ b/apps/server/src/persistence/DatabaseCompaction.ts @@ -0,0 +1,387 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Offline maintenance holds a SQLite write lock across replay verification. +import * as NodeSqlite from "node:sqlite"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import { + OrchestrationV2AppThreadJson, + OrchestrationV2DomainEventJson, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import { applyThreadPullRequestLinkSync } from "@t3tools/shared/threadPullRequests"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import { runMigrations } from "./Migrations.ts"; + +const encodeThread = Schema.encodeSync(OrchestrationV2AppThreadJson); +const sameThread = (left: OrchestrationV2AppThread, right: OrchestrationV2AppThread) => + JSON.stringify(encodeThread(left)) === JSON.stringify(encodeThread(right)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeMetadata = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** A legacy snapshot is replaceable only if this field patch reproduces every thread field. */ +export function compactThreadFieldEvent( + event: OrchestrationV2DomainEvent, + previous: OrchestrationV2AppThread | undefined, +): OrchestrationV2DomainEvent { + if (previous === undefined) return event; + if (event.type === "thread.visited") { + const payload = { lastVisitedAt: event.payload.lastVisitedAt }; + return sameThread({ ...previous, ...payload }, event.payload) + ? { ...event, type: "thread.visit-recorded", payload } + : event; + } + if (event.type !== "thread.pull-request-synced") return event; + for (const link of event.payload.pullRequests ?? []) { + if (link.snapshot === null) continue; + const payload = { + host: link.host, + repository: link.repository, + number: link.number, + snapshot: link.snapshot, + stack: link.stack, + }; + if (sameThread(applyThreadPullRequestLinkSync(previous, payload), event.payload)) { + return { ...event, type: "thread.pull-request-link-synced", payload }; + } + } + return event; +} + +export class DatabaseCompactionError extends Schema.TaggedError()( + "DatabaseCompactionError", + { cause: Schema.Defect() }, +) { + override get message() { + return "Database compaction stopped; see the cause for details. Event rewrites commit only after verification."; + } +} + +export class DatabaseCompaction extends Context.Service< + DatabaseCompaction, + { + readonly compact: (input: { + readonly databasePath: string; + readonly onProgress?: (message: string) => void; + }) => Effect.Effect< + { + readonly backupPath: string; + readonly rewrittenEvents: number; + readonly prunedReceipts: number; + readonly beforeBytes: number; + readonly afterBytes: number; + readonly verifiedTables: ReadonlyArray; + }, + DatabaseCompactionError + >; + } +>()("t3/persistence/DatabaseCompaction") {} + +const replayDatabase = (filename: string) => + Effect.gen(function* () { + const database = NodeSqliteClient.layer({ filename }); + const services = yield* Layer.build( + Layer.fresh(ProjectionStore.layer.pipe(Layer.provideMerge(database))), + ); + const sql = Context.get(services, SqlClient.SqlClient); + yield* runMigrations().pipe(Effect.provideService(SqlClient.SqlClient, sql)); + return { sql, projections: Context.get(services, ProjectionStore.ProjectionStoreV2) }; + }); + +const assertStopped = async (databasePath: string) => { + let descriptor: string; + try { + descriptor = await NodeFSP.readFile( + NodePath.join(NodePath.dirname(databasePath), "server-runtime.json"), + "utf8", + ); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === "ENOENT") return; + throw cause; + } + const state: unknown = JSON.parse(descriptor); + if ( + typeof state !== "object" || + state === null || + !("pid" in state) || + typeof state.pid !== "number" + ) { + throw new Error("Cannot establish that the server is stopped: malformed server-runtime.json."); + } + try { + process.kill(state.pid, 0); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === "ESRCH") return; + throw cause; + } + throw new Error("Stop the server before running t3 db compact."); +}; + +const syncFile = async (filename: string) => { + const handle = await NodeFSP.open(filename, "r+"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +}; + +/** Offline, explicitly invoked only. All rewrites roll back unless both fresh replays match current rows. */ +const compact = Effect.fn("DatabaseCompaction.compact")( + function* (input: { + readonly databasePath: string; + readonly onProgress?: (message: string) => void; + }) { + const progress = input.onProgress ?? (() => {}); + const databasePath = NodePath.resolve(input.databasePath); + yield* Effect.tryPromise(() => assertStopped(databasePath)); + const beforeBytes = yield* Effect.tryPromise(() => + NodeFSP.stat(databasePath).then((stat) => stat.size), + ); + const startedAt = yield* Clock.currentTimeMillis; + const backupPath = `${databasePath}.before-compact-${startedAt}`; + const scratch = yield* Effect.tryPromise(() => NodeFSP.mkdtemp(`${databasePath}.compact-`)); + yield* Effect.addFinalizer(() => + Effect.tryPromise(() => NodeFSP.rm(scratch, { recursive: true, force: true })).pipe( + Effect.ignore, + ), + ); + const database = yield* Effect.acquireRelease( + Effect.try(() => { + const db = new NodeSqlite.DatabaseSync(databasePath); + try { + db.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE"); + } catch (cause) { + db.close(); + throw cause; + } + return db; + }), + (db) => Effect.sync(() => db.close()), + ); + const result = yield* Effect.gen(function* () { + progress("Backing up the stopped database before any changes..."); + yield* Effect.tryPromise(async () => { + const source = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + try { + await NodeSqlite.backup(source, backupPath, { rate: 256 }); + } finally { + source.close(); + } + await NodeFSP.chmod(backupPath, 0o600); + await syncFile(backupPath); + const directory = await NodeFSP.open(NodePath.dirname(backupPath), "r"); + try { + await directory.sync().catch((cause: NodeJS.ErrnoException) => { + if (cause.code !== "EPERM") throw cause; + }); + } finally { + await directory.close(); + } + }); + progress( + `Backup saved: ${backupPath}. Replaying original and compacted events into fresh databases...`, + ); + const beforePath = NodePath.join(scratch, "before.sqlite"); + const afterPath = NodePath.join(scratch, "after.sqlite"); + const before = yield* replayDatabase(beforePath); + const after = yield* replayDatabase(afterPath); + const decode = Schema.decodeUnknownEffect(OrchestrationV2DomainEventJson); + const encode = Schema.encodeEffect(OrchestrationV2DomainEventJson); + const page = + database.prepare(`SELECT sequence,event_id,stream_id,event_type,occurred_at,payload_json,metadata_json + FROM orchestration_events WHERE sequence > ? AND application_event_version=2 AND aggregate_kind='thread' + ORDER BY sequence LIMIT 100`); + const update = database.prepare( + "UPDATE orchestration_events SET event_type=?,payload_json=? WHERE sequence=?", + ); + let sequence = 0; + let rewrittenEvents = 0; + let processed = 0; + let reportedAt = 0; + // Identical consecutive legacy snapshots are idempotent projections. + // Keep the proven patch until a non-legacy event could change any thread. + const repeated = new Map< + string, + { + readonly type: string; + readonly payload: string; + readonly replacementType: string; + readonly replacementPayload: string; + } + >(); + while (true) { + const rows = yield* Effect.try(() => page.all(sequence)); + if (rows.length === 0) break; + yield* before.sql.withTransaction( + after.sql.withTransaction( + Effect.gen(function* () { + for (const row of rows) { + const legacy = + row.event_type === "thread.visited" || + row.event_type === "thread.pull-request-synced"; + if (!legacy) repeated.clear(); + const key = String(row.stream_id); + const cached = repeated.get(key); + if ( + legacy && + cached !== undefined && + cached.type === row.event_type && + cached.payload === row.payload_json + ) { + yield* Effect.try(() => + update.run( + cached.replacementType, + cached.replacementPayload, + Number(row.sequence), + ), + ); + rewrittenEvents++; + sequence = Number(row.sequence); + processed++; + continue; + } + repeated.delete(key); + const metadata = decodeMetadata(String(row.metadata_json)); + const event = yield* decode({ + ...metadata, + id: row.event_id, + threadId: row.stream_id, + type: row.event_type, + occurredAt: row.occurred_at, + payload: decodeJson(String(row.payload_json)), + }); + let replacement = event; + if ( + event.type === "thread.visited" || + event.type === "thread.pull-request-synced" + ) { + const previous = yield* before.projections + .getThread(event.threadId) + .pipe(Effect.option); + replacement = compactThreadFieldEvent( + event, + previous._tag === "Some" ? previous.value : undefined, + ); + } + yield* before.projections.apply(event); + yield* after.projections.apply(replacement); + if (event.type === "turn-item.updated") { + for (const replay of [before, after]) + yield* replay.sql` + INSERT INTO orchestration_v2_turn_item_positions (thread_id, turn_item_id, ordinal) + VALUES (${event.threadId}, ${event.payload.id}, ${event.payload.ordinal}) + ON CONFLICT(thread_id, turn_item_id) DO UPDATE SET ordinal=excluded.ordinal + `; + } + if (replacement !== event) { + const encoded = yield* encode(replacement); + const replacementPayload = encodeJson(encoded.payload); + yield* Effect.try(() => + update.run(replacement.type, replacementPayload, Number(row.sequence)), + ); + if (repeated.size < 2000) + repeated.set(key, { + type: String(row.event_type), + payload: String(row.payload_json), + replacementType: replacement.type, + replacementPayload, + }); + rewrittenEvents++; + } + sequence = Number(row.sequence); + processed++; + } + }), + ), + ); + const now = yield* Clock.currentTimeMillis; + if (now - reportedAt > 2000) { + progress( + `Replayed ${processed} events; ${rewrittenEvents} equivalent field events rewritten.`, + ); + reportedAt = now; + } + yield* Effect.yieldNow; + } + progress("Comparing every projection row byte for byte..."); + const verifiedTables = yield* Effect.try(() => { + const comparison = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + try { + comparison.prepare("ATTACH DATABASE ? AS original_replay").run(beforePath); + comparison.prepare("ATTACH DATABASE ? AS compact_replay").run(afterPath); + const tables = comparison + .prepare(`SELECT name FROM sqlite_schema WHERE type='table' + AND name LIKE 'orchestration_v2_projection_%' AND name <> 'orchestration_v2_projection_metadata'`) + .all() + .map((row) => String(row.name)); + tables.push("orchestration_v2_turn_item_positions"); + for (const table of tables) { + for (const replay of ["original_replay", "compact_replay"]) { + const different = comparison + .prepare(`SELECT EXISTS(SELECT * FROM main."${table}" EXCEPT SELECT * FROM ${replay}."${table}") + OR EXISTS(SELECT * FROM ${replay}."${table}" EXCEPT SELECT * FROM main."${table}") AS different`) + .get(); + if (different?.different) + throw new Error( + `Replay differs from current ${table}; no event changes will be committed.`, + ); + } + } + return tables; + } finally { + comparison.close(); + } + }); + // There is no expiry contract for V2 receipts. Only already-imported legacy + // receipts older than 90 days can be removed without losing command deduplication. + const cutoff = DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { days: 90 })); + const prunedReceipts = yield* Effect.try(() => + Number( + database + .prepare(`DELETE FROM orchestration_command_receipts + WHERE command_type='legacy' AND accepted_at < ? AND aggregate_kind='thread' AND EXISTS ( + SELECT 1 FROM orchestration_v2_legacy_imports i WHERE i.thread_id=aggregate_id AND i.transcript_imported_at IS NOT NULL + )`) + .run(cutoff).changes, + ), + ); + yield* Effect.tryPromise(() => assertStopped(databasePath)); + yield* Effect.try(() => database.exec("COMMIT")); + progress( + "Verified rewrites committed. Running VACUUM; interruption leaves a valid compacted log and the backup.", + ); + yield* Effect.try(() => database.exec("VACUUM; PRAGMA wal_checkpoint(TRUNCATE)")); + yield* Effect.tryPromise(() => syncFile(databasePath)); + const afterBytes = yield* Effect.tryPromise(() => + NodeFSP.stat(databasePath).then((stat) => stat.size), + ); + progress( + `Compaction complete: ${beforeBytes} → ${afterBytes} bytes; backup retained at ${backupPath}.`, + ); + return { + backupPath, + rewrittenEvents, + prunedReceipts, + beforeBytes, + afterBytes, + verifiedTables, + }; + }); + return result; + }, + Effect.scoped, + Effect.mapError((cause) => new DatabaseCompactionError({ cause })), +); + +export const layer = Layer.succeed(DatabaseCompaction, { compact }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index e0169dab65cb6..85a3f6b6c9799 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -75,6 +75,7 @@ import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts"; import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; import Migration0061 from "./Migrations/061_ProjectionThreadSweepIndexes.ts"; +import Migration0062 from "./Migrations/062_ProjectionThreadReadPayload.ts"; /** * Migration loader with all migrations defined inline. @@ -152,6 +153,7 @@ export const migrationEntries = [ // Published previews ran PeerLinks as 61, so their databases skip this one (runMigrations // warns). They only lose the sweep index: no query names it with INDEXED BY. [61, "ProjectionThreadSweepIndexes", Migration0061], + [62, "ProjectionThreadReadPayload", Migration0062], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index bc08cdb9e6d5e..766262742c204 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -28,7 +28,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 61 }, (_, index) => index + 1), + Array.from({ length: 62 }, (_, index) => index + 1), ); }), ); @@ -48,6 +48,7 @@ layer("055_OrchestrationV2", (it) => { [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], [61, "ProjectionThreadSweepIndexes"], + [62, "ProjectionThreadReadPayload"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -75,6 +76,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 59, name: "McpAppModelContext" }, { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, { migration_id: 61, name: "ProjectionThreadSweepIndexes" }, + { migration_id: 62, name: "ProjectionThreadReadPayload" }, ]); const tables = yield* sql<{ readonly name: string }>` @@ -171,7 +173,7 @@ layer("055_OrchestrationV2", (it) => { '2026-07-24T00:00:00.000Z', ${payload} ) `; - yield* runMigrations(); + yield* runMigrations({ toMigrationInclusive: 61 }); const sweep = sql<{ readonly thread_id: string; readonly pull_requests: number }>` SELECT thread_id, json_array_length(payload_json, '$.pullRequests') AS pull_requests FROM orchestration_v2_projection_threads @@ -210,7 +212,9 @@ layer("055_OrchestrationV2", (it) => { ON orchestration_v2_projection_threads(updated_at, thread_id) WHERE deleted_at IS NULL AND archived_at IS NULL `; - assert.deepStrictEqual(yield* runMigrations(), [[61, "ProjectionThreadSweepIndexes"]]); + assert.deepStrictEqual(yield* runMigrations({ toMigrationInclusive: 61 }), [ + [61, "ProjectionThreadSweepIndexes"], + ]); const [index] = yield* sql<{ readonly sql: string }>` SELECT sql FROM sqlite_master WHERE name = 'orchestration_v2_projection_threads_active_idx' `; @@ -229,7 +233,8 @@ layer("055_OrchestrationV2", (it) => { const sql = yield* SqlClient.SqlClient; yield* runMigrations({ toMigrationInclusive: 60 }); yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (61, 'PeerLinks')`; - assert.deepStrictEqual(yield* runMigrations(), []); + // Ids above the preview's latest still run. + assert.deepStrictEqual(yield* runMigrations(), [[62, "ProjectionThreadReadPayload"]]); assert.deepStrictEqual(skippedWarnings, [["61_ProjectionThreadSweepIndexes"]]); assert.deepStrictEqual( yield* sql` diff --git a/apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts b/apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts new file mode 100644 index 0000000000000..25a49d1c6ef0c --- /dev/null +++ b/apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts @@ -0,0 +1,39 @@ +import { OrchestrationV2AppThreadJson } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { + compactThreadPullRequests, + encodeThreadReadPayload, +} from "../../orchestration-v2/ThreadReadPayload.ts"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS orchestration_v2_projection_thread_reads ( + thread_id TEXT PRIMARY KEY NOT NULL, + compact_payload_json TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + `; + const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2AppThreadJson)); + let after = ""; + while (true) { + const rows = yield* sql<{ thread_id: string; payload_json: string }>` + SELECT thread_id, payload_json FROM orchestration_v2_projection_threads + WHERE thread_id > ${after} ORDER BY thread_id LIMIT 100 + `; + if (rows.length === 0) break; + for (const row of rows) { + const thread = yield* decode(row.payload_json); + const payload = yield* encodeThreadReadPayload(thread); + const compact = yield* encodeThreadReadPayload(compactThreadPullRequests(thread)); + yield* sql` + INSERT OR REPLACE INTO orchestration_v2_projection_thread_reads (thread_id, payload_json, compact_payload_json) + VALUES (${row.thread_id}, ${payload}, ${compact}) + `; + } + after = rows[rows.length - 1]!.thread_id; + } +}); diff --git a/apps/server/src/serviceDatabaseBackup.ts b/apps/server/src/serviceDatabaseBackup.ts new file mode 100644 index 0000000000000..d35660b2ebd48 --- /dev/null +++ b/apps/server/src/serviceDatabaseBackup.ts @@ -0,0 +1,63 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The standalone launcher uses Node built-ins only. +import * as NodeSqlite from "node:sqlite"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +/** + * Hold the source read transaction until the stopped server's WAL is captured. + * A WAL reader prevents checkpoint/reset from discarding frames newer than its + * snapshot. Overlaying the final WAL on the online snapshot therefore reproduces + * every commit through shutdown, including writes made during the long copy. + * Copying just an online snapshot would lose those acknowledged writes on rollback. + */ +export async function prepareServiceDatabaseBackup(databasePath: string, stagingDir: string) { + await NodeFSP.rm(stagingDir, { recursive: true, force: true }); + await NodeFSP.mkdir(stagingDir, { recursive: true, mode: 0o700 }); + const source = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + try { + source.exec("PRAGMA busy_timeout = 5000; BEGIN"); + source.prepare("SELECT name FROM sqlite_schema LIMIT 1").get(); + const mode = source.prepare("PRAGMA journal_mode").get(); + if (mode?.journal_mode !== "wal") { + throw new Error("Online update backup requires a WAL database."); + } + const destination = NodePath.join(stagingDir, "database"); + await NodeSqlite.backup(source, destination, { rate: 256 }); + // SQLite backup produces a standalone database. Enable WAL before installing + // the source WAL; never open the destination between copying WAL and restore. + const snapshot = new NodeSqlite.DatabaseSync(destination); + try { + snapshot.exec("PRAGMA journal_mode = WAL"); + } finally { + snapshot.close(); + } + const file = await NodeFSP.open(destination, "r+"); + try { + await file.sync(); + } finally { + await file.close(); + } + return { + async finish() { + try { + await NodeFSP.copyFile(`${databasePath}-wal`, NodePath.join(stagingDir, "database-wal")); + const wal = await NodeFSP.open(NodePath.join(stagingDir, "database-wal"), "r+"); + try { + await wal.sync(); + } finally { + await wal.close(); + } + } catch (cause) { + if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT")) throw cause; + } + }, + close() { + source.close(); + }, + }; + } catch (cause) { + source.close(); + await NodeFSP.rm(stagingDir, { recursive: true, force: true }); + throw cause; + } +} diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index df2ab9e9cd061..db37377dd0456 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -3,6 +3,8 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as NodeSqlite from "node:sqlite"; +import { prepareServiceDatabaseBackup } from "./serviceDatabaseBackup.ts"; import { Launcher, readServiceState, writeServiceState } from "./serviceLauncher.ts"; import { @@ -98,6 +100,48 @@ const writeFakeRuntime = ( }); it.layer(NodeServices.layer)("service state persistence", (it) => { + it.effect("online update backups retain acknowledged writes after the snapshot", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-online-backup-" }); + const sourcePath = path.join(root, "source.sqlite"); + const writer = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(sourcePath)), + (db) => Effect.sync(() => db.close()), + ); + writer.exec( + "PRAGMA journal_mode=WAL; CREATE TABLE records(id INTEGER PRIMARY KEY, value TEXT); INSERT INTO records VALUES(1,'before'); PRAGMA wal_checkpoint(TRUNCATE)", + ); + const prepared = yield* Effect.acquireRelease( + Effect.tryPromise(() => + prepareServiceDatabaseBackup(sourcePath, path.join(root, "backup")), + ), + (backup) => Effect.sync(() => backup.close()), + ); + writer.exec( + "UPDATE records SET value='acknowledged'; INSERT INTO records VALUES(2,'after'); CREATE TABLE later(id INTEGER)", + ); + assert.equal(writer.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get()?.busy, 1); + yield* Effect.tryPromise(() => prepared.finish()); + const restored = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(path.join(root, "backup", "database"))), + (db) => Effect.sync(() => db.close()), + ); + assert.deepEqual( + restored + .prepare("SELECT * FROM records ORDER BY id") + .all() + .map((row) => ({ ...row })), + [ + { id: 1, value: "acknowledged" }, + { id: 2, value: "after" }, + ], + ); + assert.equal(restored.prepare("PRAGMA integrity_check").get()?.integrity_check, "ok"); + assert.equal(restored.prepare("SELECT COUNT(*) AS n FROM later").get()?.n, 0); + }), + ); it.effect("durably replaces and strictly reads one state document", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -296,6 +340,69 @@ if (context.update?.status === "pending") { }), ); + it.effect("restores an online backup when a trial migration fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-online-rollback-" }); + const databasePath = path.join(root, "userdata", "state.sqlite"); + yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); + const original = new NodeSqlite.DatabaseSync(databasePath); + original.exec( + "PRAGMA journal_mode=WAL; CREATE TABLE records(value TEXT); INSERT INTO records VALUES('before')", + ); + original.close(); + const childSource = ` +import { DatabaseSync } from "node:sqlite"; +if (process.argv[2] === "__service-preflight") { + console.log(JSON.stringify({status:"ready",version:"1.1.0",launcherProtocol:3,needsDatabaseBackup:true})); + process.exit(0); +} +const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +if (context.update?.status === "pending") { + const db = new DatabaseSync(context.update.dbPath); + db.exec("CREATE TABLE trial_migration(id INTEGER); UPDATE records SET value='trial'"); + db.close(); + process.exit(1); +} else if (context.update === undefined) { + process.send({type:"request-update",targetVersion:"1.1.0",dbPath:${JSON.stringify(databasePath)}}); + setInterval(() => {}, 1000); +} else process.exit(0); +`; + for (const version of ["1.0.0", "1.1.0"]) { + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); + } + const launcher = new Launcher(root, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }); + yield* Effect.promise(() => launcher.run().catch(() => undefined)); + const state = yield* Effect.promise(() => + readServiceState(path.join(root, "runtime", "service-state.json")), + ); + assert.equal(state.activeVersion, "1.0.0"); + assert.equal(state.update?.status, "rolled-back"); + const restored = new NodeSqlite.DatabaseSync(databasePath); + try { + assert.equal(restored.prepare("SELECT value FROM records").get()?.value, "before"); + assert.equal( + restored + .prepare("SELECT COUNT(*) n FROM sqlite_schema WHERE name='trial_migration'") + .get()?.n, + 0, + ); + assert.equal(restored.prepare("PRAGMA integrity_check").get()?.integrity_check, "ok"); + } finally { + restored.close(); + } + }), + ); + it.effect("restores the database when a migrating trial exits", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index cfdd3d75b57ee..7ca6969b57d6d 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -9,6 +9,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import { prepareServiceDatabaseBackup } from "./serviceDatabaseBackup.ts"; import type { PendingServiceUpdate, @@ -134,6 +135,43 @@ async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate } } +/** Ask the target executable, whose migration manifest may differ from this launcher. */ +async function targetNeedsDatabaseBackup(baseDir: string, pending: PendingServiceUpdate) { + const target = runtimePaths(baseDir, pending.targetVersion); + return new Promise((resolve) => { + NodeChildProcess.execFile( + target.entryPath, + [ + "__service-preflight", + "--database-path", + pending.dbPath, + "--launcher-protocol", + String(SERVICE_LAUNCHER_PROTOCOL), + ], + { timeout: 30_000, maxBuffer: 64 * 1024 }, + (error, stdout) => { + if (error !== null) { + resolve(undefined); + return; + } + try { + const result = JSON.parse(stdout); + resolve( + result.status === "ready" && + result.version === pending.targetVersion && + result.launcherProtocol === SERVICE_LAUNCHER_PROTOCOL && + typeof result.needsDatabaseBackup === "boolean" + ? result.needsDatabaseBackup + : undefined, + ); + } catch { + resolve(undefined); + } + }, + ); + }); +} + const restoreMarkerPath = (baseDir: string, updateId: string) => NodePath.join(databaseBackupDir(baseDir, updateId), RESTORE_MARKER); @@ -398,10 +436,12 @@ export class Launcher { await this.#startTrial(update); } - async #startTrial(pending: PendingServiceUpdate): Promise { + async #startTrial(pending: PendingServiceUpdate, needsBackup?: boolean): Promise { // The previous child is dead here, so all three SQLite files are quiescent. try { - await backupDatabaseOnce(this.#baseDir, pending); + if ((needsBackup ?? (await targetNeedsDatabaseBackup(this.#baseDir, pending))) !== false) { + await backupDatabaseOnce(this.#baseDir, pending); + } } catch { await this.#returnToPrevious(pending, "failed", "db-backup-failed"); return; @@ -530,9 +570,41 @@ export class Launcher { return; } this.#timer = undefined; + // Old targets without the preflight field retain the original stopped-copy + // path. New targets copy online while the active child continues serving. + const needsBackup = await targetNeedsDatabaseBackup(this.#baseDir, pending); + const backupDir = databaseBackupDir(this.#baseDir, pending.id); + const stagingDir = `${backupDir}.staging`; + let prepared: Awaited> | undefined; + try { + if (needsBackup === true && !(await pathExists(backupDir))) { + prepared = await prepareServiceDatabaseBackup(pending.dbPath, stagingDir); + } + } catch { + // A failed online copy must not turn into a long outage. + const failed = terminalUpdate({ pending, status: "failed", reason: "db-backup-failed" }); + this.#state = { ...this.#state, update: failed }; + await writeServiceState(this.#statePath, this.#state); + return; + } this.#child = null; - await terminateChild(child.process); - await this.#startTrial(pending); + try { + await terminateChild(child.process); + if (prepared !== undefined) { + await prepared.finish(); + await syncDirectory(stagingDir); + await NodeFSP.rename(stagingDir, backupDir); + await syncDirectory(NodePath.dirname(backupDir)); + } + } catch { + prepared?.close(); + prepared = undefined; + await this.#returnToPrevious(pending, "failed", "db-backup-failed"); + return; + } finally { + prepared?.close(); + } + await this.#startTrial(pending, needsBackup); } async #handlePrepared(child: ManagedChild, updateId: string): Promise { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 65003d5d3b5be..5f5a32bfb747c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -981,6 +981,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc function* (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; + readonly compactPullRequests?: boolean; }) { const sql = yield* SqlClient.SqlClient; const threadManagement = yield* ThreadManagementService.ThreadManagementService; @@ -1009,7 +1010,10 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc const base = buildActiveShellSnapshot( yield* loadShellSnapshotParts({ sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), + readThreads: threadManagement.readShellSnapshot({ + location: "active", + compactPullRequests: input.compactPullRequests === true, + }), listProjects: projects.listShells(), latestSequence: applicationEvents.latestApplicationSequence, }), @@ -1059,7 +1063,9 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc if ("aggregateKind" in stored) { return yield* projectItem(stored); } - const shell = yield* threadManagement.getThreadShell(stored.event.threadId); + const shell = yield* threadManagement.getThreadShell(stored.event.threadId, { + compactPullRequests: input.compactPullRequests === true, + }); return shellStreamItemFromThreadShell({ stored, shell }); }), { concurrency: 8 }, diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 237528702b4d1..672f65eccbe85 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -18,7 +18,12 @@ import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { findProjectForChangeRequest, useOpenPrLink } from "~/lib/openPullRequestLink"; import { cn } from "~/lib/utils"; import { useShortcutModifierState } from "~/shortcutModifierState"; -import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities"; +import { + useProjects, + useServerConfigs, + useThreadProjection, + useThreadShell, +} from "~/state/entities"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -328,7 +333,9 @@ export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread } function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) { - const thread = useThreadShell(threadRef); + const shell = useThreadShell(threadRef); + const detail = useThreadProjection(threadRef); + const thread = detail?.projection.thread ?? shell; const projects = useProjects(); const environmentProjects = useMemo( () => diff --git a/docs/user/updating.md b/docs/user/updating.md index 3461dae9844c3..7cf4315f213b3 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -79,6 +79,15 @@ update can roll back to the previous version. If the update still fails: at once. Hover it to see which providers it will update. Providers that only offer a manual update command are not included. +## Compact a long-lived database + +Stop the environment's server, then run `t3 db compact --base-dir ` +on its host. The command saves a database backup, rewrites equivalent legacy +PR-sync and visit events, and reclaims unused space. It reports progress and +refuses event changes if replay differs from the current projections. It never +runs automatically. Keep the printed backup path until you have checked the +environment after restarting. Interruption leaves a valid database and the backup. + ## Mobile updates To update an environment from your phone, open **Settings → Environments** and diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index dbc4405a9faa0..8739de25b7254 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -387,7 +387,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") // If the authoritative refresh failed, omit the cached cursor so the // socket fallback sends a complete snapshot for this new session. if (!canResume || Option.isNone(current.snapshot)) { - return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + return { + compactPullRequests: true, + ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + }; } if (!supportsCompletionMarker) { // Without a completion marker there is no synchronized signal for a @@ -400,6 +403,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } return { afterSequence: current.snapshot.value.snapshotSequence, + compactPullRequests: true, ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), }; }), diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index e0294ba0dfe72..4c25910acbea3 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -47,7 +47,7 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( ...input, group: "orchestration", method: "GET", - url: (urls) => urls.shellSnapshot(), + url: (urls) => urls.shellSnapshot({ query: { compactPullRequests: "true" } }), timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, // The body is decoded here so each thread's pull request links can decode after its // rows. Every declared error is an `EnvironmentHttpCommonError`, so decoding one keeps @@ -56,6 +56,7 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( client .shellSnapshot({ headers: withOrchestrationProtocolHeader(headers), + query: { compactPullRequests: "true" }, responseMode: "response-only", }) .pipe(Effect.flatMap(decodeShellSnapshotResponse)), diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 76e9d0a2ffba5..a2d10a029540f 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -611,6 +611,7 @@ class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("shellSnapshot", "/api/orchestration/shell", { headers: OrchestrationProtocolHeaders, + query: Schema.Struct({ compactPullRequests: Schema.optional(Schema.String) }), success: OrchestrationV2ShellSnapshot, error: EnvironmentOrchestrationSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index a3248fc8fff9d..3dbe9b06a86e8 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3277,6 +3277,8 @@ export const OrchestrationV2SubscribeShellInput = Schema.Struct({ afterSequence: Schema.optionalKey(NonNegativeInt), /** Requests a marker between initial catch-up and live delivery. */ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** PR views load full snapshots separately; shells need badge and chain summaries. */ + compactPullRequests: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeShellInput = typeof OrchestrationV2SubscribeShellInput.Type; From 651a4222a7327be75c69895f746d142f89db5c0f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 08:47:44 +0000 Subject: [PATCH 011/241] perf(server): send repeated shell pull request stacks once --- .../orchestration-v2/ProjectionStore.test.ts | 8 ++ .../src/orchestration-v2/ThreadReadPayload.ts | 18 ++++ apps/server/src/orchestration-v2/http.ts | 18 +++- apps/server/src/ws.ts | 17 +++- .../src/state/shell-sync.test.ts | 96 ++++++++++++++++++- packages/client-runtime/src/state/shell.ts | 19 +++- .../src/state/shellPullRequests.ts | 21 +++- packages/contracts/src/orchestrationV2.ts | 9 +- 8 files changed, 195 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 64d5436dd349c..b182d103ec873 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -43,6 +43,7 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import { compactThreadPullRequests, + compactShellPullRequestStacks, decodeThreadReadPayload, encodeThreadReadPayload, } from "./ThreadReadPayload.ts"; @@ -4963,6 +4964,13 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { compact.pullRequests, ); assert.deepEqual((yield* store.getThread(threadId)).pullRequests, rich.pullRequests); + const shell = yield* store.getThreadShell(threadId, { compactPullRequests: true }); + assert.isNotNull(shell); + const wire = compactShellPullRequestStacks(shell!); + assert.equal(wire.pullRequests?.[1]?.stackReference, 0); + assert.isNull(wire.pullRequests?.[1]?.stack); + assert.equal(wire.pullRequests?.[2]?.stack?.base, "older-base"); + assert.isUndefined(wire.pullRequests?.[2]?.stackReference); yield* syncPullRequests("restore", synced.pullRequests ?? []); assert.equal(yield* phase, "running"); assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); diff --git a/apps/server/src/orchestration-v2/ThreadReadPayload.ts b/apps/server/src/orchestration-v2/ThreadReadPayload.ts index e093742003a19..e62e8ece975b6 100644 --- a/apps/server/src/orchestration-v2/ThreadReadPayload.ts +++ b/apps/server/src/orchestration-v2/ThreadReadPayload.ts @@ -1,5 +1,6 @@ import { type OrchestrationV2AppThread, + type OrchestrationV2ThreadShell, OrchestrationV2AppThreadJson, ThreadPullRequestStack, } from "@t3tools/contracts"; @@ -64,6 +65,23 @@ const ReadPayload = Schema.fromJsonString( stackIndices: Schema.Array(Schema.NullOr(Schema.Number)), }), ); + +/** Preserve every stack vintage while sending identical layer lists only once. */ +export const compactShellPullRequestStacks = (thread: OrchestrationV2ThreadShell) => { + if (thread.pullRequests === undefined) return thread; + const stacks = new Map(); + return { + ...thread, + pullRequests: thread.pullRequests.map((link, index) => { + if (link.stack === null) return link; + const key = JSON.stringify(link.stack); + const reference = stacks.get(key); + if (reference !== undefined) return { ...link, stack: null, stackReference: reference }; + stacks.set(key, index); + return link; + }), + }; +}; const encode = Schema.encodeEffect(ReadPayload); const decode = Schema.decodeUnknownEffect(ReadPayload); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index d206e6de43a30..ff74f5eba80e8 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -31,6 +31,7 @@ import * as ProjectStore from "./ProjectStore.ts"; import { buildActiveShellSnapshot, loadShellSnapshotParts } from "./ShellStream.ts"; import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; +import { compactShellPullRequestStacks } from "./ThreadReadPayload.ts"; function isThreadNotFound(error: unknown): boolean { return ( @@ -75,17 +76,28 @@ export const layer = HttpApiBuilder.group( ), ); - const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* (compactPullRequests: boolean) { + const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* ( + compactPullRequests: boolean, + ) { const base = buildActiveShellSnapshot( yield* loadShellSnapshotParts({ sql, - readThreads: threadManagement.readShellSnapshot({ location: "active", compactPullRequests }), + readThreads: threadManagement.readShellSnapshot({ + location: "active", + compactPullRequests, + }), listProjects: projectStore.listShells(), latestSequence: applicationEvents.latestApplicationSequence, }), ); const projects = yield* enrichProjectShells(base.projects); - return { ...base, projects }; + return { + ...base, + projects, + threads: compactPullRequests + ? base.threads.map(compactShellPullRequestStacks) + : base.threads, + }; }); const loadThreadSnapshot = Effect.fn("http.orchestration.loadThreadSnapshot")(function* ( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5f5a32bfb747c..b57eec268762b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -20,6 +20,7 @@ import * as Stream from "effect/Stream"; import { rpcInitialItems } from "./rpcInitialItems.ts"; import { subscribeChatGptHandoff } from "./provider/CodexChatGptHandoff.ts"; import { subscribeCodexAuthCallback } from "./provider/CodexAuthCallback.ts"; +import { compactShellPullRequestStacks } from "./orchestration-v2/ThreadReadPayload.ts"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AcpRegistryOperationError, @@ -1020,7 +1021,13 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc ); const enriched = yield* enrichProjectShells(base.projects); return { - snapshot: { ...base, projects: enriched.projects } as OrchestrationV2ShellSnapshot, + snapshot: { + ...base, + projects: enriched.projects, + threads: input.compactPullRequests + ? base.threads.map(compactShellPullRequestStacks) + : base.threads, + } as OrchestrationV2ShellSnapshot, resolvedRepositoryIdentityRoots: enriched.resolvedRepositoryIdentityRoots, }; }); @@ -1066,7 +1073,13 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc const shell = yield* threadManagement.getThreadShell(stored.event.threadId, { compactPullRequests: input.compactPullRequests === true, }); - return shellStreamItemFromThreadShell({ stored, shell }); + return shellStreamItemFromThreadShell({ + stored, + shell: + shell !== null && input.compactPullRequests + ? compactShellPullRequestStacks(shell) + : shell, + }); }), { concurrency: 8 }, ); diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index b20d3e9cda199..3cfc6ad551a07 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId, ORCHESTRATION_V2_WS_METHODS, - type OrchestrationV2ShellSnapshot, + OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, ThreadId, type ThreadPullRequestLink, @@ -15,6 +15,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; +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"; @@ -31,6 +32,7 @@ import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { makeEnvironmentShellState } from "./shell.ts"; import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; +import { decodeShellSnapshotDeferringPullRequests } from "./shellPullRequests.ts"; import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; const TARGET = new PrimaryConnectionTarget({ @@ -53,6 +55,9 @@ const LIVE_SHELL_SNAPSHOT: OrchestrationV2ShellSnapshot = { ...v2ShellSnapshot, snapshotSequence: 1, }; +const encodeShellSnapshotJson = Schema.encodeEffect( + Schema.toCodecJson(OrchestrationV2ShellSnapshot), +); function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { @@ -66,6 +71,57 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { } describe("environment shell synchronization", () => { + it.effect("restores shared native stacks after deferred HTTP decoding", () => + Effect.gen(function* () { + const stack = { + kind: "native" as const, + id: "stack:shared", + number: 1, + url: "https://github.com/pingdotgg/t3code/stack/1", + base: "main", + layers: [7, 8, 9].map((number) => ({ + number, + headBranch: `branch-${number}`, + state: "open" as const, + })), + }; + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack, + }; + const expected = [ + link, + { ...link, number: 8 }, + { ...link, number: 9, stack: { ...stack, base: "older-base" } }, + ]; + const source = { + ...v2ShellSnapshot, + threads: [ + { + ...v2ThreadShell, + pullRequests: [ + link, + { ...link, number: 8, stack: null, stackReference: 0 }, + expected[2]!, + ], + }, + ], + }; + const json = yield* encodeShellSnapshotJson(source); + const decoded = yield* decodeShellSnapshotDeferringPullRequests(json); + expect(decoded.threads[0]?.pullRequests).toBeUndefined(); + const loading = yield* decoded.loadPullRequests.pipe(Effect.forkScoped); + yield* TestClock.adjust("1 millis"); + const restored = yield* Fiber.join(loading); + expect(restored.get(v2ThreadShell.id)).toEqual(expected); + }), + ); it.effect("publishes live state before persistence and preserves it when ready", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); @@ -155,6 +211,44 @@ describe("environment shell synchronization", () => { const state = yield* SubscriptionRef.get(shellState); expect(state.status).toBe("live"); expect(Option.getOrThrow(state.snapshot)).toEqual(LIVE_SHELL_SNAPSHOT); + const stack = { + kind: "native" as const, + id: "stack:live", + number: 1, + url: "https://github.com/pingdotgg/t3code/stack/1", + base: "main", + layers: [7, 8].map((number) => ({ + number, + headBranch: `branch-${number}`, + state: "open" as const, + })), + }; + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack, + }; + yield* Queue.offer(events, { + kind: "thread.updated", + sequence: 2, + location: "active", + thread: { + ...v2ThreadShell, + pullRequests: [link, { ...link, number: 8, stack: null, stackReference: 0 }], + }, + }); + const updated = yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((value) => Option.getOrNull(value.snapshot)?.snapshotSequence === 2), + Stream.runHead, + ); + expect( + Option.getOrThrow(Option.getOrThrow(updated).snapshot).threads[0]?.pullRequests, + ).toEqual([link, { ...link, number: 8 }]); }), ); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 8739de25b7254..c428ec0926a1d 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -29,7 +29,7 @@ import { runCachePersistence } from "./cachePersistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; import type { RpcSession } from "../rpc/session.ts"; import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; -import type { DeferredShellSnapshot } from "./shellPullRequests.ts"; +import { type DeferredShellSnapshot, restoreShellPullRequestStacks } from "./shellPullRequests.ts"; import { applyShellStreamEvent, mergeShellSnapshotProjects, @@ -192,7 +192,22 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") let waiting = yield* Ref.get(awaitingCompletion); let next = initial; let receivedSnapshot = false; - for (const item of items) { + for (const received of items) { + const item = + received.kind === "snapshot" + ? { + ...received, + snapshot: { + ...received.snapshot, + threads: received.snapshot.threads.map(restoreShellPullRequestStacks), + archivedThreads: received.snapshot.archivedThreads.map( + restoreShellPullRequestStacks, + ), + }, + } + : received.kind === "thread.updated" + ? { ...received, thread: restoreShellPullRequestStacks(received.thread) } + : received; if (item.kind === "synchronized") { waiting = false; if (Option.isSome(next.snapshot)) { diff --git a/packages/client-runtime/src/state/shellPullRequests.ts b/packages/client-runtime/src/state/shellPullRequests.ts index aedc90d25d901..f3ac9eee1b793 100644 --- a/packages/client-runtime/src/state/shellPullRequests.ts +++ b/packages/client-runtime/src/state/shellPullRequests.ts @@ -1,5 +1,7 @@ import { OrchestrationV2ShellSnapshot, + OrchestrationV2ShellPullRequestLink, + type OrchestrationV2ThreadShell, type ThreadId, ThreadPullRequestLink, } from "@t3tools/contracts"; @@ -24,8 +26,23 @@ export interface DeferredShellSnapshot extends OrchestrationV2ShellSnapshot { const yieldToHost = Effect.sleep("1 millis"); const decodeThreadLinks = Schema.decodeUnknownEffect( - Schema.UndefinedOr(Schema.toCodecJson(Schema.Array(ThreadPullRequestLink))), + Schema.UndefinedOr(Schema.toCodecJson(Schema.Array(OrchestrationV2ShellPullRequestLink))), ); + +const restoreStackReferences = (links: ReadonlyArray) => + links.some((link) => link.stackReference !== undefined) + ? links.map(({ stackReference, ...link }) => ({ + ...link, + stack: stackReference === undefined ? link.stack : (links[stackReference]?.stack ?? null), + })) + : links; + +/** Restore before reducers, cached snapshots, badges or PR navigation use the links. */ +export const restoreShellPullRequestStacks = (thread: OrchestrationV2ThreadShell) => { + if (thread.pullRequests === undefined) return thread; + const links = restoreStackReferences(thread.pullRequests); + return links === thread.pullRequests ? thread : { ...thread, pullRequests: links }; +}; // Bounds each uninterrupted stretch of decoding, so taps and frames run between batches. const LINKS_PER_BATCH = 400; @@ -76,7 +93,7 @@ export function deferPullRequests( }).pipe(Effect.as(undefined)), ), ); - if (links !== undefined) linksByThreadId.set(threadId, links); + if (links !== undefined) linksByThreadId.set(threadId, restoreStackReferences(links)); } return linksByThreadId as ReadonlyMap>; }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 3dbe9b06a86e8..5c1533834abdd 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1864,6 +1864,13 @@ export const OrchestrationV2LatestVisibleMessageSummary = Schema.Struct({ export type OrchestrationV2LatestVisibleMessageSummary = typeof OrchestrationV2LatestVisibleMessageSummary.Type; +/** Opted-in shells reference an earlier link's identical native stack. */ +export const OrchestrationV2ShellPullRequestLink = Schema.Struct({ + ...ThreadPullRequestLink.fields, + stackReference: Schema.optionalKey(NonNegativeInt), +}); +export type OrchestrationV2ShellPullRequestLink = typeof OrchestrationV2ShellPullRequestLink.Type; + export const OrchestrationV2ThreadShell = Schema.Struct({ ...OrchestrationV2CreationFields, id: ThreadId, @@ -1877,7 +1884,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ worktreePath: Schema.NullOr(TrimmedNonEmptyString), /** Pull request the user linked to this thread (#8160). */ linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), - pullRequests: Schema.optional(Schema.Array(ThreadPullRequestLink)), + pullRequests: Schema.optional(Schema.Array(OrchestrationV2ShellPullRequestLink)), /** Pull request discovered from the thread's current branch. */ branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), lineage: OrchestrationV2AppThreadLineage, From 85097da018efd03766b2046511606e691106a45d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 08:56:46 +0000 Subject: [PATCH 012/241] fix(server): discard failed online backup staging files --- apps/server/src/serviceLauncher.test.ts | 10 ++++++++++ apps/server/src/serviceLauncher.ts | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index db37377dd0456..afeac885ed03d 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -208,6 +208,9 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); const statePath = path.join(root, "runtime", "service-state.json"); + const stagingDir = path.join(root, "runtime", "db-backup", "failed-online-backup.staging"); + yield* fs.makeDirectory(stagingDir, { recursive: true }); + yield* fs.writeFileString(path.join(stagingDir, "database"), "incomplete online backup"); yield* writeFakeRuntime( fs, path, @@ -218,6 +221,12 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { writeServiceState(statePath, { protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.0.0", + update: { + id: "failed-online-backup", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + status: "failed", + }, }), ); @@ -230,6 +239,7 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE))); yield* Effect.promise(() => stopping); yield* Effect.promise(() => running); + assert.isFalse(yield* fs.exists(stagingDir)); }), ); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 7ca6969b57d6d..2012128dc7487 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -216,8 +216,10 @@ async function restoreDatabaseBackup( async function discardDatabaseBackup(baseDir: string, updateId: string): Promise { const backupDir = databaseBackupDir(baseDir, updateId); - if (!(await pathExists(backupDir))) return; + const stagingDir = `${backupDir}.staging`; + if (!(await pathExists(backupDir)) && !(await pathExists(stagingDir))) return; await NodeFSP.rm(backupDir, { recursive: true, force: true }); + await NodeFSP.rm(stagingDir, { recursive: true, force: true }); await syncDirectory(NodePath.dirname(backupDir)); } @@ -585,6 +587,7 @@ export class Launcher { const failed = terminalUpdate({ pending, status: "failed", reason: "db-backup-failed" }); this.#state = { ...this.#state, update: failed }; await writeServiceState(this.#statePath, this.#state); + await discardDatabaseBackup(this.#baseDir, pending.id).catch(() => undefined); return; } this.#child = null; From b8835f8d14563b84483480dbc73c89e6b98f0a7c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 09:47:19 +0000 Subject: [PATCH 013/241] docs(server): clarify interrupted compaction backups --- docs/user/updating.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/updating.md b/docs/user/updating.md index 7cf4315f213b3..4f154d59e7752 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -86,7 +86,7 @@ on its host. The command saves a database backup, rewrites equivalent legacy PR-sync and visit events, and reclaims unused space. It reports progress and refuses event changes if replay differs from the current projections. It never runs automatically. Keep the printed backup path until you have checked the -environment after restarting. Interruption leaves a valid database and the backup. +environment after restarting. Interruption leaves a valid database; completed backups are retained. ## Mobile updates From b3937c7ae24246d5cd8659efe9cd40bdc57c9aa7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 10:25:33 +0000 Subject: [PATCH 014/241] fix(server): retain pull request settlement timestamps --- .../orchestration-v2/ProjectionStore.test.ts | 27 +++++++++++++++++++ .../src/orchestration-v2/ThreadReadPayload.ts | 4 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index b182d103ec873..80c2ec98f0a24 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -41,6 +41,7 @@ import ProjectionThreadSweepIndexes from "../persistence/Migrations/061_Projecti import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import { resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; import { compactThreadPullRequests, compactShellPullRequestStacks, @@ -4971,6 +4972,32 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.isNull(wire.pullRequests?.[1]?.stack); assert.equal(wire.pullRequests?.[2]?.stack?.base, "older-base"); assert.isUndefined(wire.pullRequests?.[2]?.stackReference); + const terminalAt = DateTime.formatIso(DateTime.add(at, { seconds: 1 })); + for (const state of ["merged", "closed"] as const) { + yield* syncPullRequests(`terminal-${state}`, [ + { + ...rich.pullRequests[0]!, + watch: undefined, + snapshot: { + ...snapshot, + state, + ...(state === "merged" ? { mergedAt: terminalAt } : { closedAt: terminalAt }), + }, + }, + ]); + const candidate = (yield* store.getSettlementCandidates(threadId))[0]; + assert.isDefined(candidate); + assert.deepEqual( + resolveAutoSettlementAt({ + thread: candidate!, + pullRequest: null, + nowMs: DateTime.toEpochMillis(DateTime.add(at, { seconds: 2 })), + autoSettleAfterDays: null, + autoSettleOnMerge: true, + }), + candidate!.latestRunCompletedAt, + ); + } yield* syncPullRequests("restore", synced.pullRequests ?? []); assert.equal(yield* phase, "running"); assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); diff --git a/apps/server/src/orchestration-v2/ThreadReadPayload.ts b/apps/server/src/orchestration-v2/ThreadReadPayload.ts index e62e8ece975b6..011eb1581e080 100644 --- a/apps/server/src/orchestration-v2/ThreadReadPayload.ts +++ b/apps/server/src/orchestration-v2/ThreadReadPayload.ts @@ -8,7 +8,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; -/** Keep shell badges, search and chain order; PR views read canonical snapshots. */ +/** Keep shell badges, search, chain order and settlement dates; PR views read canonical snapshots. */ export const compactThreadPullRequests = ( thread: OrchestrationV2AppThread, ): OrchestrationV2AppThread => { @@ -41,6 +41,8 @@ export const compactThreadPullRequests = ( isDraft: link.snapshot.isDraft, updatedAt: link.snapshot.updatedAt, syncedAt: link.snapshot.syncedAt, + ...(link.snapshot.mergedAt == null ? {} : { mergedAt: link.snapshot.mergedAt }), + ...(link.snapshot.closedAt == null ? {} : { closedAt: link.snapshot.closedAt }), }, stack: link.stack === null From 011bf89ffb3342e57622b3c3feb0f9bd3e3a2e99 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 10:58:37 +0000 Subject: [PATCH 015/241] perf(server): keep usage recovery reads compact --- .../orchestration-v2/ProjectionStore.test.ts | 85 ++++++++++++++++--- .../src/orchestration-v2/ProjectionStore.ts | 19 +++-- 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 80c2ec98f0a24..455dfe4e77551 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2474,6 +2474,38 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const otherThreadId = yield* addRolledBackRecoveryCandidate("other-limit-shell"); const original = (yield* store.getThreadProjection(threadId)).runs[0]!; const now = yield* DateTime.now; + const thread = yield* store.getThread(threadId); + yield* store.apply({ + id: EventId.make("event:limit-shell:pull-request"), + type: "thread.pull-request-synced", + threadId, + occurredAt: now, + payload: { + ...thread, + pullRequests: [ + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "manual", + linkedAt: DateTime.formatIso(now), + stack: null, + snapshot: { + state: "open", + title: "Recovery with a linked PR", + headBranch: "recovery", + baseBranch: "main", + isDraft: false, + updatedAt: DateTime.formatIso(now), + syncedAt: DateTime.formatIso(now), + additions: 100, + deletions: 20, + }, + }, + ], + }, + }); const limitItem = { id: TurnItemId.make("limit-shell:error"), threadId, @@ -2706,15 +2738,20 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ), ); const reset = DateTime.makeUnsafe(limitItem.failure.resetAt); + const recoveryBaseline = yield* store.getThread(threadId); const recovery = { runId: original.id, resetAt: limitItem.failure.resetAt, autoResume: true, requestId: CommandId.make("recovery:choice"), }; - yield* sql`UPDATE orchestration_v2_projection_threads - SET payload_json = json_set(payload_json, '$.limitRecovery', json(${encodeUnknownJsonString(recovery)})) - WHERE thread_id = ${threadId}`; + yield* store.apply({ + id: EventId.make("event:limit-shell:recovery-choice"), + type: "thread.metadata-updated", + threadId, + occurredAt: now, + payload: { ...recoveryBaseline, limitRecovery: recovery }, + }); // Armed future retries need no state decoding until they become due. assert.isUndefined( (yield* store.getLimitRecoveryCandidates({ ...recoveryOptions, autoResume: true })).find( @@ -2745,17 +2782,32 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ), ); yield* sql`DELETE FROM orchestration_v2_projection_runtime_requests WHERE runtime_request_id = 'limit-shell:pending-request'`; - yield* sql`UPDATE orchestration_v2_projection_threads - SET payload_json = json_set(payload_json, '$.snoozedUntil', ${DateTime.formatIso(DateTime.add(reset, { minutes: 1 }))}) - WHERE thread_id = ${threadId}`; + yield* store.apply({ + id: EventId.make("event:limit-shell:recovery-snooze"), + type: "thread.snoozed", + threadId, + occurredAt: now, + payload: { + ...(yield* store.getThread(threadId)), + snoozedUntil: DateTime.add(reset, { minutes: 1 }), + }, + }); assert.isUndefined( (yield* store.getLimitRecoveryCandidates({ ...recoveryOptions, now: reset })).find( (row) => row.id === threadId, ), ); - yield* sql`UPDATE orchestration_v2_projection_threads - SET payload_json = json_set(payload_json, '$.snoozedUntil', NULL, '$.limitRecovery.autoResume', json('false')) - WHERE thread_id = ${threadId}`; + yield* store.apply({ + id: EventId.make("event:limit-shell:recovery-disabled"), + type: "thread.metadata-updated", + threadId, + occurredAt: now, + payload: { + ...recoveryBaseline, + snoozedUntil: null, + limitRecovery: { ...recovery, autoResume: false }, + }, + }); assert.isUndefined( (yield* store.getLimitRecoveryCandidates({ ...recoveryOptions, @@ -2763,7 +2815,13 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { autoResume: true, })).find((row) => row.id === threadId), ); - yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = ${originalRow!.payload_json} WHERE thread_id = ${threadId}`; + yield* store.apply({ + id: EventId.make("event:limit-shell:recovery-restored"), + type: "thread.metadata-updated", + threadId, + occurredAt: now, + payload: recoveryBaseline, + }); const session = { id: ProviderSessionId.make("session:limit-shell:shared"), driver, @@ -2846,6 +2904,13 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { // A new attempt's root cannot inherit an earlier attempt's limit. yield* applyRun("failed", NodeId.make("new-attempt-root")); yield* assertSummary(null, null); + yield* store.apply({ + id: EventId.make("event:limit-shell:pull-request-removed"), + type: "thread.pull-request-synced", + threadId, + occurredAt: now, + payload: { ...(yield* store.getThread(threadId)), pullRequests: thread.pullRequests }, + }); }), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 2386eb5ccfd95..4049a6dc2999e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -3524,7 +3524,7 @@ export const layer: Layer.Layer = readonly failure_payload_json: string; readonly last_error: string | null; }>` - SELECT t.payload_json, r.run_id, r.completed_at, + SELECT summary.compact_payload_json AS payload_json, r.run_id, r.completed_at, item.payload_json AS failure_payload_json, ( SELECT json_extract(session.payload_json, '$.lastError') @@ -3537,6 +3537,7 @@ export const layer: Layer.Layer = LIMIT 1 ) AS last_error FROM orchestration_v2_projection_threads t + INNER JOIN orchestration_v2_projection_thread_reads summary ON summary.thread_id = t.thread_id INNER JOIN orchestration_v2_projection_runs r ON r.run_id = ( SELECT latest.run_id FROM orchestration_v2_projection_runs latest WHERE latest.thread_id = t.thread_id @@ -3571,19 +3572,19 @@ export const layer: Layer.Layer = AND julianday(json_extract(item.payload_json, '$.failure.resetAt')) > julianday(COALESCE(r.completed_at, t.updated_at)) AND ( ( - json_extract(t.payload_json, '$.limitRecovery.runId') IS r.run_id - AND json_extract(t.payload_json, '$.limitRecovery.resetAt') IS json_extract(item.payload_json, '$.failure.resetAt') - AND json_extract(t.payload_json, '$.limitRecovery.autoResume') = 1 + json_extract(summary.compact_payload_json, '$.thread.limitRecovery.runId') IS r.run_id + AND json_extract(summary.compact_payload_json, '$.thread.limitRecovery.resetAt') IS json_extract(item.payload_json, '$.failure.resetAt') + AND json_extract(summary.compact_payload_json, '$.thread.limitRecovery.autoResume') = 1 AND julianday(json_extract(item.payload_json, '$.failure.resetAt')) <= julianday(${DateTime.formatIso(options.now)}) AND ( - json_extract(t.payload_json, '$.snoozedUntil') IS NULL - OR julianday(json_extract(t.payload_json, '$.snoozedUntil')) <= julianday(${DateTime.formatIso(options.now)}) + json_extract(summary.compact_payload_json, '$.thread.snoozedUntil') IS NULL + OR julianday(json_extract(summary.compact_payload_json, '$.thread.snoozedUntil')) <= julianday(${DateTime.formatIso(options.now)}) ) ) OR ( ( - json_extract(t.payload_json, '$.limitRecovery.runId') IS NOT r.run_id - OR json_extract(t.payload_json, '$.limitRecovery.resetAt') IS NOT json_extract(item.payload_json, '$.failure.resetAt') + json_extract(summary.compact_payload_json, '$.thread.limitRecovery.runId') IS NOT r.run_id + OR json_extract(summary.compact_payload_json, '$.thread.limitRecovery.resetAt') IS NOT json_extract(item.payload_json, '$.failure.resetAt') ) AND ( ${booleanInt(options.autoResume)} @@ -3599,7 +3600,7 @@ export const layer: Layer.Layer = `; const candidates: Array = []; for (const row of rows) { - const thread = yield* decodeThreadPayload(row.payload_json); + const thread = yield* decodeThreadReadPayload(row.payload_json); if (thread.settledOverride === "settled") continue; const item = yield* decodeTurnItemPayload(row.failure_payload_json); const summary = threadErrorSummary( From 645715598569ee315ee50cdf1af3966b56dc17f6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 13:12:01 +0000 Subject: [PATCH 016/241] fix(server): thread read projection follows payloads written by any build The read projection (migration 62) was kept current only by this build's projection writer. An older build sharing the database rewrites thread payloads without it, so shells, sweeps and usage-limit recovery read stale pull requests or missed new threads, the same gap migration 61 closed for its sweep columns. Triggers now mark a thread's read row stale on any thread write, whoever makes it. This build's writer clears the mark when it rewrites the row, and readers use the thread payload for a stale or missing row. The backfill skips a payload it cannot decode instead of failing startup. --- .../orchestration-v2/ProjectionStore.test.ts | 5 + .../src/orchestration-v2/ProjectionStore.ts | 122 ++++++++++++------ .../062_ProjectionThreadReadPayload.ts | 51 +++++++- .../reconcileV2PreviewMigration.test.ts | 2 + 4 files changed, 138 insertions(+), 42 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 455dfe4e77551..cce5a92318868 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -5090,6 +5090,11 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { SET payload_json = json_set(payload_json, '$.pullRequests', json('[]')) WHERE thread_id = ${threadId}`; assert.deepEqual(yield* store.getThreadsWithPullRequests(), []); + // Its write marks the read projection stale, so shells read the payload it wrote. + assert.deepEqual( + (yield* store.getThreadShell(threadId, { compactPullRequests: true }))?.pullRequests, + [], + ); yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = ${stored!.payload_json} WHERE thread_id = ${threadId}`; assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4049a6dc2999e..9818d55482f90 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -963,9 +963,16 @@ type PayloadRow = { readonly payload_json: string; }; -type ShellThreadRow = { +/** + * `payload_json` is the thread read projection when `read_payload` is 1. Otherwise the read row + * is missing or stale (another build rewrote the thread) and it is the thread payload itself. + */ +type ThreadReadRow = PayloadRow & { + readonly read_payload: number; +}; + +type ShellThreadRow = ThreadReadRow & { readonly thread_id: string; - readonly payload_json: string; readonly forked_from_run_source_thread_id: string | null; readonly latest_run_id: string | null; readonly latest_run_status: string | null; @@ -1001,6 +1008,7 @@ type SettlementThreadRow = Pick< ShellThreadRow, | "thread_id" | "payload_json" + | "read_payload" | "latest_run_id" | "latest_run_status" | "latest_run_requested_at" @@ -1076,6 +1084,12 @@ const encodeContextTransferPayload = Schema.encodeEffect( const decodeThreadPayload = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2AppThreadJsonSchema), ); +const decodeThreadReadRow = (row: ThreadReadRow, compactPullRequests: boolean) => + row.read_payload === 1 + ? decodeThreadReadPayload(row.payload_json) + : decodeThreadPayload(row.payload_json).pipe( + Effect.map((thread) => (compactPullRequests ? compactThreadPullRequests(thread) : thread)), + ); const decodeRunPayload = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2RunJsonSchema), ); @@ -1809,7 +1823,8 @@ export const layer: Layer.Layer = INSERT INTO orchestration_v2_projection_thread_reads (thread_id, payload_json, compact_payload_json) VALUES (${thread.id}, ${payload}, ${compact}) ON CONFLICT(thread_id) DO UPDATE SET payload_json = excluded.payload_json, - compact_payload_json = excluded.compact_payload_json + compact_payload_json = excluded.compact_payload_json, + source_stale = 0 `; }); @@ -2726,6 +2741,12 @@ export const layer: Layer.Layer = const thread = yield* decodeThreadPayload(row.payload_json); const updatedThread = { ...thread, updatedAt: event.occurredAt }; const payloadJson = yield* encodeThreadPayload(updatedThread); + // The thread write marks its read row stale. Only a row that was current + // before it can be patched back to current. + const readRows = yield* sql<{ readonly source_stale: number }>` + SELECT source_stale FROM orchestration_v2_projection_thread_reads + WHERE thread_id = ${event.threadId} + `; yield* sql` UPDATE orchestration_v2_projection_threads SET @@ -2735,13 +2756,16 @@ export const layer: Layer.Layer = `; // Activity-only events change no PR fields. Patch the small read // projections without encoding their stacks again on every token. - const updatedAt = stringField(parseEncodedPayload(payloadJson), "updatedAt"); - yield* sql` - UPDATE orchestration_v2_projection_thread_reads - SET payload_json = json_set(payload_json, '$.thread.updatedAt', ${updatedAt}), - compact_payload_json = json_set(compact_payload_json, '$.thread.updatedAt', ${updatedAt}) - WHERE thread_id = ${event.threadId} - `; + if (readRows[0]?.source_stale === 0) { + const updatedAt = stringField(parseEncodedPayload(payloadJson), "updatedAt"); + yield* sql` + UPDATE orchestration_v2_projection_thread_reads + SET payload_json = json_set(payload_json, '$.thread.updatedAt', ${updatedAt}), + compact_payload_json = json_set(compact_payload_json, '$.thread.updatedAt', ${updatedAt}), + source_stale = 0 + WHERE thread_id = ${event.threadId} + `; + } } } }).pipe( @@ -3517,14 +3541,21 @@ export const layer: Layer.Layer = function* (options: Parameters[0]) { // Indexed latest-run and root-error lookups avoid reading run histories, // counting transcript items, or walking fork ancestors on scheduler ticks. - const rows = yield* sql<{ - readonly payload_json: string; - readonly run_id: string; - readonly completed_at: string | null; - readonly failure_payload_json: string; - readonly last_error: string | null; - }>` - SELECT summary.compact_payload_json AS payload_json, r.run_id, r.completed_at, + // A missing or stale read row (another build rewrote the thread) reads the thread payload. + const threadField = (path: string) => + sql`CASE WHEN summary.thread_id IS NULL THEN json_extract(t.payload_json, ${`$.${path}`}) ELSE json_extract(summary.compact_payload_json, ${`$.thread.${path}`}) END`; + const rows = yield* sql< + ThreadReadRow & { + readonly run_id: string; + readonly completed_at: string | null; + readonly failure_payload_json: string; + readonly last_error: string | null; + } + >` + SELECT + CASE WHEN summary.thread_id IS NULL THEN t.payload_json ELSE summary.compact_payload_json END AS payload_json, + summary.thread_id IS NOT NULL AS read_payload, + r.run_id, r.completed_at, item.payload_json AS failure_payload_json, ( SELECT json_extract(session.payload_json, '$.lastError') @@ -3537,7 +3568,8 @@ export const layer: Layer.Layer = LIMIT 1 ) AS last_error FROM orchestration_v2_projection_threads t - INNER JOIN orchestration_v2_projection_thread_reads summary ON summary.thread_id = t.thread_id + LEFT JOIN orchestration_v2_projection_thread_reads summary + ON summary.thread_id = t.thread_id AND summary.source_stale = 0 INNER JOIN orchestration_v2_projection_runs r ON r.run_id = ( SELECT latest.run_id FROM orchestration_v2_projection_runs latest WHERE latest.thread_id = t.thread_id @@ -3572,19 +3604,19 @@ export const layer: Layer.Layer = AND julianday(json_extract(item.payload_json, '$.failure.resetAt')) > julianday(COALESCE(r.completed_at, t.updated_at)) AND ( ( - json_extract(summary.compact_payload_json, '$.thread.limitRecovery.runId') IS r.run_id - AND json_extract(summary.compact_payload_json, '$.thread.limitRecovery.resetAt') IS json_extract(item.payload_json, '$.failure.resetAt') - AND json_extract(summary.compact_payload_json, '$.thread.limitRecovery.autoResume') = 1 + ${threadField("limitRecovery.runId")} IS r.run_id + AND ${threadField("limitRecovery.resetAt")} IS json_extract(item.payload_json, '$.failure.resetAt') + AND ${threadField("limitRecovery.autoResume")} = 1 AND julianday(json_extract(item.payload_json, '$.failure.resetAt')) <= julianday(${DateTime.formatIso(options.now)}) AND ( - json_extract(summary.compact_payload_json, '$.thread.snoozedUntil') IS NULL - OR julianday(json_extract(summary.compact_payload_json, '$.thread.snoozedUntil')) <= julianday(${DateTime.formatIso(options.now)}) + ${threadField("snoozedUntil")} IS NULL + OR julianday(${threadField("snoozedUntil")}) <= julianday(${DateTime.formatIso(options.now)}) ) ) OR ( ( - json_extract(summary.compact_payload_json, '$.thread.limitRecovery.runId') IS NOT r.run_id - OR json_extract(summary.compact_payload_json, '$.thread.limitRecovery.resetAt') IS NOT json_extract(item.payload_json, '$.failure.resetAt') + ${threadField("limitRecovery.runId")} IS NOT r.run_id + OR ${threadField("limitRecovery.resetAt")} IS NOT json_extract(item.payload_json, '$.failure.resetAt') ) AND ( ${booleanInt(options.autoResume)} @@ -3600,7 +3632,7 @@ export const layer: Layer.Layer = `; const candidates: Array = []; for (const row of rows) { - const thread = yield* decodeThreadReadPayload(row.payload_json); + const thread = yield* decodeThreadReadRow(row, true); if (thread.settledOverride === "settled") continue; const item = yield* decodeTurnItemPayload(row.failure_payload_json); const summary = threadErrorSummary( @@ -5214,7 +5246,11 @@ export const layer: Layer.Layer = sql` SELECT t.thread_id, - ${compactPullRequests ? sql`summary.compact_payload_json` : sql`summary.payload_json`} AS payload_json, + CASE + WHEN summary.thread_id IS NULL THEN t.payload_json + ELSE ${compactPullRequests ? sql`summary.compact_payload_json` : sql`summary.payload_json`} + END AS payload_json, + summary.thread_id IS NOT NULL AS read_payload, CASE WHEN json_extract(t.payload_json, '$.forkedFrom.type') = 'run' THEN json_extract(t.payload_json, '$.forkedFrom.threadId') @@ -5358,7 +5394,8 @@ export const layer: Layer.Layer = AND i.run_id IS NULL ) AS runless_item_count FROM orchestration_v2_projection_threads t - JOIN orchestration_v2_projection_thread_reads summary ON summary.thread_id = t.thread_id + LEFT JOIN orchestration_v2_projection_thread_reads summary + ON summary.thread_id = t.thread_id AND summary.source_stale = 0 -- The newest run not waiting in a held queue, matching latestUnheldRun. LEFT JOIN orchestration_v2_projection_runs presented ON presented.run_id = ( SELECT candidate.run_id @@ -5529,7 +5566,9 @@ export const layer: Layer.Layer = // provider sessions or inherited fork rows. Reject parked and busy // threads before loading the remaining candidates' background work. const rows = yield* sql` - SELECT t.thread_id, summary.compact_payload_json AS payload_json, + SELECT t.thread_id, + CASE WHEN summary.thread_id IS NULL THEN t.payload_json ELSE summary.compact_payload_json END AS payload_json, + summary.thread_id IS NOT NULL AS read_payload, r.run_id AS latest_run_id, r.status AS latest_run_status, r.requested_at AS latest_run_requested_at, @@ -5551,7 +5590,8 @@ export const layer: Layer.Layer = LIMIT 1 ) AS latest_user_authored_message_at FROM orchestration_v2_projection_threads t - JOIN orchestration_v2_projection_thread_reads summary ON summary.thread_id = t.thread_id + LEFT JOIN orchestration_v2_projection_thread_reads summary + ON summary.thread_id = t.thread_id AND summary.source_stale = 0 LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = ( SELECT latest.run_id FROM orchestration_v2_projection_runs latest WHERE latest.thread_id = t.thread_id @@ -5584,7 +5624,7 @@ export const layer: Layer.Layer = }); return yield* Effect.forEach(rows, (row) => Effect.gen(function* () { - const thread = yield* decodeThreadReadPayload(row.payload_json); + const thread = yield* decodeThreadReadRow(row, true); const status = shellStatusFromStoredRunStatus(row.latest_run_status); const latestRunId = row.latest_run_id === null ? null : RunId.make(row.latest_run_id); @@ -5642,10 +5682,13 @@ export const layer: Layer.Layer = threadId, ) => Effect.gen(function* () { - const rows = yield* sql` - SELECT summary.payload_json + const rows = yield* sql` + SELECT + CASE WHEN summary.thread_id IS NULL THEN t.payload_json ELSE summary.payload_json END AS payload_json, + summary.thread_id IS NOT NULL AS read_payload FROM orchestration_v2_projection_threads t - JOIN orchestration_v2_projection_thread_reads summary ON summary.thread_id = t.thread_id + LEFT JOIN orchestration_v2_projection_thread_reads summary + ON summary.thread_id = t.thread_id AND summary.source_stale = 0 WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`} AND t.archived_at IS NULL -- Read from the sweep index (migration 061) rather than each payload. @@ -5653,7 +5696,7 @@ export const layer: Layer.Layer = ORDER BY t.updated_at ASC, t.thread_id ASC `; return yield* Effect.forEach(rows, (row) => - decodeThreadReadPayload(row.payload_json).pipe( + decodeThreadReadRow(row, false).pipe( Effect.map((thread): ProjectionThreadPullRequests => ({ id: thread.id, projectId: thread.projectId, @@ -5668,6 +5711,7 @@ export const layer: Layer.Layer = const shellThreadStateFromRow = (input: { readonly row: ShellThreadRow; + readonly compactPullRequests: boolean; readonly runOrdinalsByThreadId: ReadonlyMap>; readonly itemCountsByThreadId: ReadonlyMap>; readonly providerThreadsByThreadId: ReadonlyMap< @@ -5687,7 +5731,7 @@ export const layer: Layer.Layer = providerThreadsByThreadId, pendingTurnItemsByThreadId, } = input; - const thread = yield* decodeThreadReadPayload(row.payload_json); + const thread = yield* decodeThreadReadRow(row, input.compactPullRequests); const pendingRuntimeRequest = row.pending_request_payload_json !== null ? yield* decodeRuntimeRequestPayload(row.pending_request_payload_json) @@ -5873,6 +5917,7 @@ export const layer: Layer.Layer = sequenceRows, providerThreadRows, pendingTurnItemRows, + compactPullRequests: options?.compactPullRequests ?? false, }; }); @@ -5884,6 +5929,7 @@ export const layer: Layer.Layer = sequenceRows, providerThreadRows, pendingTurnItemRows, + compactPullRequests, }: Effect.Success>) => Effect.gen(function* () { const { runOrdinalsByThreadId, itemCountsByThreadId } = runMapsByThreadId({ @@ -5895,6 +5941,7 @@ export const layer: Layer.Layer = const states = yield* Effect.forEach(threadRows, (row) => shellThreadStateFromRow({ row, + compactPullRequests, runOrdinalsByThreadId, itemCountsByThreadId, providerThreadsByThreadId, @@ -5990,6 +6037,7 @@ export const layer: Layer.Layer = const states = yield* Effect.forEach([...rowsByThreadId.values()], (row) => shellThreadStateFromRow({ row, + compactPullRequests: options?.compactPullRequests ?? false, runOrdinalsByThreadId, itemCountsByThreadId, providerThreadsByThreadId, diff --git a/apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts b/apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts index 25a49d1c6ef0c..25c4abc19b04a 100644 --- a/apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts +++ b/apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts @@ -1,5 +1,6 @@ import { OrchestrationV2AppThreadJson } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; @@ -8,15 +9,46 @@ import { encodeThreadReadPayload, } from "../../orchestration-v2/ThreadReadPayload.ts"; +/** + * A read projection of each thread's payload with repeated pull request stacks stored once, plus + * a compact copy for the shell and background sweeps. ProjectionStore writes it next to the + * thread row. + * + * Another build sharing this database rewrites thread payloads without it. The triggers mark the + * read row stale on any thread write, whoever makes it, and readers use the thread payload for a + * stale or missing row until this build writes the thread again. + */ export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // Development builds of this change created the table without `source_stale`. It is derived + // from the thread rows, so rebuild it. + yield* sql`DROP TABLE IF EXISTS orchestration_v2_projection_thread_reads`; yield* sql` - CREATE TABLE IF NOT EXISTS orchestration_v2_projection_thread_reads ( + CREATE TABLE orchestration_v2_projection_thread_reads ( thread_id TEXT PRIMARY KEY NOT NULL, + source_stale INTEGER NOT NULL DEFAULT 0, compact_payload_json TEXT NOT NULL, payload_json TEXT NOT NULL ) `; + yield* sql`DROP TRIGGER IF EXISTS orchestration_v2_projection_thread_reads_stale_on_insert`; + yield* sql` + CREATE TRIGGER orchestration_v2_projection_thread_reads_stale_on_insert + AFTER INSERT ON orchestration_v2_projection_threads + BEGIN + UPDATE orchestration_v2_projection_thread_reads SET source_stale = 1 + WHERE thread_id = NEW.thread_id; + END + `; + yield* sql`DROP TRIGGER IF EXISTS orchestration_v2_projection_thread_reads_stale_on_update`; + yield* sql` + CREATE TRIGGER orchestration_v2_projection_thread_reads_stale_on_update + AFTER UPDATE OF payload_json ON orchestration_v2_projection_threads + BEGIN + UPDATE orchestration_v2_projection_thread_reads SET source_stale = 1 + WHERE thread_id = NEW.thread_id; + END + `; const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2AppThreadJson)); let after = ""; while (true) { @@ -26,11 +58,20 @@ export default Effect.gen(function* () { `; if (rows.length === 0) break; for (const row of rows) { - const thread = yield* decode(row.payload_json); - const payload = yield* encodeThreadReadPayload(thread); - const compact = yield* encodeThreadReadPayload(compactThreadPullRequests(thread)); + // A payload this build cannot decode keeps no read row; readers use the thread payload. + const encoded = yield* decode(row.payload_json).pipe( + Effect.flatMap((thread) => + Effect.all([ + encodeThreadReadPayload(thread), + encodeThreadReadPayload(compactThreadPullRequests(thread)), + ]), + ), + Effect.option, + ); + if (Option.isNone(encoded)) continue; + const [payload, compact] = encoded.value; yield* sql` - INSERT OR REPLACE INTO orchestration_v2_projection_thread_reads (thread_id, payload_json, compact_payload_json) + INSERT INTO orchestration_v2_projection_thread_reads (thread_id, payload_json, compact_payload_json) VALUES (${row.thread_id}, ${payload}, ${compact}) `; } diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index f03bdbd65fdf3..6440b95b4242e 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -42,6 +42,7 @@ describe("V2 preview upgrade", () => { [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], [61, "ProjectionThreadSweepIndexes"], + [62, "ProjectionThreadReadPayload"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -126,6 +127,7 @@ describe("V2 preview upgrade", () => { [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], [61, "ProjectionThreadSweepIndexes"], + [62, "ProjectionThreadReadPayload"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From cc140c7336881dcb5356dae0effc1c723838bf7d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 07:15:09 +0000 Subject: [PATCH 017/241] perf(rpc): send vcs summaries and incremental server config --- .../features/threads/ThreadGitControls.tsx | 2 +- .../features/threads/git/GitCommitSheet.tsx | 24 +++- .../features/threads/git/GitOverviewSheet.tsx | 2 +- .../threads/git/gitSheetComponents.tsx | 7 +- .../SourceControlDiscovery.test.ts | 12 ++ .../sourceControl/SourceControlDiscovery.ts | 10 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 45 +++++++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 62 ++++++++- apps/server/src/ws.test.ts | 25 ++++ apps/server/src/ws.ts | 118 ++++++++++++++++-- apps/web/src/components/GitActionsControl.tsx | 18 ++- .../settings/SourceControlSettings.tsx | 2 +- .../client-runtime/src/rpc/session.test.ts | 117 ++++++++++++++++- packages/client-runtime/src/rpc/session.ts | 57 +++++++-- .../src/state/serverConfigProjection.ts | 13 ++ packages/client-runtime/src/state/vcs.ts | 46 +++++-- packages/contracts/src/git.ts | 8 ++ packages/contracts/src/rpc.ts | 5 +- packages/contracts/src/server.ts | 27 ++++ packages/shared/package.json | 4 + packages/shared/src/git.ts | 3 + packages/shared/src/serverConfigDelta.ts | 72 +++++++++++ 22 files changed, 635 insertions(+), 44 deletions(-) create mode 100644 packages/shared/src/serverConfigDelta.ts diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 257fe6272de54..cb3dd8afe024a 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -51,7 +51,7 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string { const parts: string[] = []; if (gitStatus.hasWorkingTreeChanges) { - parts.push(`${gitStatus.workingTree.files.length} changed`); + parts.push(`${gitStatus.workingTree.fileCount ?? gitStatus.workingTree.files.length} changed`); } else if (gitStatus.aheadCount === 0 && gitStatus.behindCount === 0) { parts.push("Clean"); } diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 5a04951b4665e..4f603a0041669 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -31,18 +31,27 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const { canWriteSourceControl, canChangeThreadBranch } = gitActions; + const summary = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null ? vcsEnvironment.status({ environmentId: selectedThread.environmentId, - input: { cwd: selectedThreadCwd }, + input: { cwd: selectedThreadCwd, includeFiles: true }, }) : null, ); const busy = gitState.gitOperationLabel !== null; - const isDefaultRef = gitStatus.data?.isDefaultRef ?? false; + const status = gitStatus.data ?? summary.data; + const isDefaultRef = status?.isDefaultRef ?? false; const allFiles = gitStatus.data?.workingTree?.files ?? []; const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -117,7 +126,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { Branch - {gitStatus.data?.refName ?? "(detached HEAD)"} + {status?.refName ?? "(detached HEAD)"} {isDefaultRef ? ( @@ -134,7 +143,9 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { Files - {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} + {gitStatus.isPending && gitStatus.data === null + ? "Loading files..." + : `${selectedFiles.length} selected · +${selectedInsertions} / -${selectedDeletions}`} @@ -161,7 +172,10 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {allFiles.length === 0 ? ( - No changed files are available to commit. + {gitStatus.error ?? + (gitStatus.isPending + ? "Loading files..." + : "No changed files are available to commit.")} ) : !isEditingFiles ? ( diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 1741c527d91d2..2dd9a6e0f2085 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -235,7 +235,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { return undefined; } if (item.dialogAction === "commit" && status.hasWorkingTreeChanges) { - const fileCount = status.workingTree?.files.length ?? 0; + const fileCount = status.workingTree?.fileCount ?? status.workingTree?.files.length ?? 0; return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`; } if (item.dialogAction === "push" && (status.aheadCount ?? 0) > 0) { diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index c358466e22d21..f583acc196c2a 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -137,7 +137,10 @@ export function statusSummary( gitStatus: { readonly isRepo?: boolean; readonly hasWorkingTreeChanges?: boolean; - readonly workingTree?: { readonly files: readonly { readonly path: string }[] }; + readonly workingTree?: { + readonly files: readonly { readonly path: string }[]; + readonly fileCount?: number; + }; readonly aheadCount?: number; readonly behindCount?: number; readonly pr?: { readonly state?: string; readonly number?: number } | null; @@ -153,7 +156,7 @@ export function statusSummary( const parts: string[] = []; if (gitStatus.hasWorkingTreeChanges) { - const fileCount = gitStatus.workingTree?.files.length ?? 0; + const fileCount = gitStatus.workingTree?.fileCount ?? gitStatus.workingTree?.files.length ?? 0; parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"} changed`); } else { parts.push("Clean"); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index b4503fb7535fe..21d1934a6beed 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/process"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; import { VcsProcessSpawnError } from "@t3tools/contracts"; @@ -87,8 +88,10 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.effect("reports implemented tools separately from locally available executables", () => { + let probeCount = 0; const processMock = { run: (input: VcsProcess.VcsProcessInput) => { + probeCount += 1; if (input.command === "git") { return Effect.succeed(processOutput("git version 2.51.0\n")); } @@ -153,6 +156,15 @@ it.effect("reports implemented tools separately from locally available executabl return Effect.gen(function* () { const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; const result = yield* discovery.discover; + const firstProbeCount = probeCount; + yield* Effect.all([discovery.discover, discovery.discover], { concurrency: 2 }); + assert.strictEqual(probeCount, firstProbeCount); + yield* discovery.refresh; + assert.isAbove(probeCount, firstProbeCount); + const refreshedProbeCount = probeCount; + yield* TestClock.adjust("1 minute"); + yield* discovery.discover; + assert.isAbove(probeCount, refreshedProbeCount); assert.deepStrictEqual( result.versionControlSystems.map((item) => ({ diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index f56985fefd639..6eec5f37c2f80 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -61,6 +61,7 @@ export class SourceControlDiscovery extends Context.Service< SourceControlDiscovery, { readonly discover: Effect.Effect; + readonly refresh: Effect.Effect; } >()("t3/sourceControl/SourceControlDiscovery") {} @@ -129,14 +130,19 @@ export const make = Effect.gen(function* () { ); }; - return SourceControlDiscovery.of({ - discover: Effect.all({ + const [discover, invalidate] = yield* Effect.cachedInvalidateWithTTL( + Effect.all({ versionControlSystems: Effect.all( VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, { concurrency: "unbounded" }, ), sourceControlProviders: sourceControlProviders.discover, }), + "1 minute", + ); + return SourceControlDiscovery.of({ + discover, + refresh: invalidate.pipe(Effect.andThen(discover)), }); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 35d013433c499..d0064292655b5 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -69,6 +69,51 @@ const baseStatus: VcsStatusResult = { ...baseRemoteStatus, }; +it.effect("summarizes passive VCS subscriptions and acknowledges unchanged versions", () => { + const state = { + currentLocalStatus: { + ...baseLocalStatus, + hasWorkingTreeChanges: true, + workingTree: { + insertions: 1, + deletions: 0, + files: [{ path: "changed.ts", insertions: 1, deletions: 0 }], + }, + }, + currentRemoteStatus: null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const read = (options: { compact?: boolean; includeFiles?: boolean; knownVersion?: string }) => + broadcaster + .streamStatus({ cwd: "/repo", includeRemote: false, ...options }) + .pipe(Stream.runHead); + const initial = Option.getOrThrow(yield* read({ compact: true })); + assert.strictEqual(initial._tag, "snapshot"); + if (initial._tag !== "snapshot") return; + assert.strictEqual(initial.local.workingTree.fileCount, 1); + assert.deepStrictEqual(initial.local.workingTree.files, []); + assert.isDefined(initial.version); + const replay = Option.getOrThrow( + yield* read({ compact: true, knownVersion: initial.version! }), + ); + assert.deepStrictEqual(replay, { _tag: "unchanged", version: initial.version }); + for (const options of [{}, { compact: true, includeFiles: true }]) { + const full = Option.getOrThrow(yield* read(options)); + assert.strictEqual(full._tag, "snapshot"); + if (full._tag === "snapshot") + assert.deepStrictEqual( + full.local.workingTree.files, + state.currentLocalStatus.workingTree.files, + ); + } + }).pipe(Effect.provide(layerTestFor(state))); +}); + function layerTestFor(state: { currentLocalStatus: VcsStatusLocalResult; currentRemoteStatus: VcsStatusRemoteResult | null; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 468bc3301bfcb..701a798f532af 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - stream deduplication needs a synchronous content digest. +import * as NodeCrypto from "node:crypto"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -21,7 +23,7 @@ import type { VcsStatusStreamEvent, VcsStatusSubscriptionInput, } from "@t3tools/contracts"; -import { mergeGitStatusParts } from "@t3tools/shared/git"; +import { applyGitStatusStreamEvent, mergeGitStatusParts } from "@t3tools/shared/git"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; @@ -733,7 +735,63 @@ export const make = Effect.gen(function* () { Stream.filter((event) => event.cwd === cwd), Stream.map((event) => event.event), ), - ).pipe(Stream.ensuring(release)); + ).pipe( + (stream) => + input.compact !== true + ? stream + : stream.pipe( + Stream.mapAccum( + () => ({ status: null as VcsStatusResult | null, version: input.knownVersion }), + ( + current, + event, + ): readonly [ + { status: VcsStatusResult | null; version: string | undefined }, + ReadonlyArray, + ] => { + const status = applyGitStatusStreamEvent(current.status, event); + const { + hasUpstream, + aheadCount, + behindCount, + aheadOfDefaultCount, + pr, + ...local + } = status; + const summary = + input.includeFiles === true + ? local + : { + ...local, + workingTree: { + ...local.workingTree, + fileCount: local.workingTree.files.length, + files: [], + }, + }; + const remote = { + hasUpstream, + aheadCount, + behindCount, + aheadOfDefaultCount, + pr, + }; + const version = NodeCrypto.createHash("sha256") + .update(JSON.stringify([summary, remote])) + .digest("hex"); + const next = { status, version }; + if (version === current.version) { + return [ + next, + current.status === null ? [{ _tag: "unchanged", version }] : [], + ]; + } + return [next, [{ _tag: "snapshot", local: summary, remote, version }]]; + }, + ), + ), + Stream.ensuring(release), + ); }), ); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 0f735efd31c70..c073b2275e2f7 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -24,6 +24,7 @@ import { resolveAvailableEditorsForConfig, shouldUseBoundedThreadSnapshot, withLateEditorConfig, + withConfigDeltas, } from "./ws.ts"; it("accepts only the current orchestration protocol before websocket RPC setup", () => { @@ -73,6 +74,30 @@ const settingsUpdated = (settings: object): ServerConfigStreamEvent => ({ payload: { settings: settings as ServerConfig["settings"] }, }); +it.effect("sends config deltas and a conditional reconnect acknowledgement", () => + Effect.gen(function* () { + const config = snapshotConfig({ providers: [] }); + const snapshot: ServerConfigStreamEvent = { version: 1, type: "snapshot", config }; + const first = yield* withConfigDeltas( + Stream.make(snapshot, snapshot, settingsUpdated({ enabled: true })), + ).pipe(Stream.runCollect); + assert.strictEqual(first.length, 2); + assert.strictEqual(first[0]?.type, "snapshot"); + assert.strictEqual(first[1]?.type, "configDelta"); + if (first[0]?.type !== "snapshot" || first[0].configVersion === undefined) return; + const reordered = { + ...snapshot, + config: Object.fromEntries(Object.entries(config).reverse()) as ServerConfig, + }; + const reconnect = yield* withConfigDeltas(Stream.make(reordered), first[0].configVersion).pipe( + Stream.runCollect, + ); + assert.deepStrictEqual(reconnect, [ + { version: 1, type: "configUnchanged", configVersion: first[0].configVersion }, + ]); + }), +); + it.effect("resends late editors without rolling back updates already sent", () => Effect.gen(function* () { const settingsSent = yield* Deferred.make(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b57eec268762b..f19fc355bd419 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,7 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - config stream deduplication needs a synchronous content digest. +import * as NodeCrypto from "node:crypto"; import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; +import { diffServerConfig } from "@t3tools/shared/serverConfigDelta"; import * as Crypto from "effect/Crypto"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; @@ -357,6 +360,74 @@ export const withLateEditorConfig = ( ); }; +/** Opted-in clients retain catalogs and acknowledge unchanged reconnects. */ +export const withConfigDeltas = ( + events: Stream.Stream, + knownVersion?: string, +) => + events.pipe( + Stream.mapAccum( + () => null as ClientServerConfig | null, + ( + current, + event, + ): readonly [ClientServerConfig | null, ReadonlyArray] => { + if ( + event.type === "environmentThemesUpdated" || + event.type === "usageLimitSourcesUpdated" + ) { + return [current, [event]]; + } + const next = + event.type === "snapshot" + ? event.config + : current === null + ? null + : event.type === "keybindingsUpdated" + ? { ...current, ...event.payload } + : event.type === "providerStatuses" + ? { ...current, providers: event.payload.providers } + : event.type === "settingsUpdated" + ? { ...current, settings: event.payload.settings } + : current; + if (next === null) return [current, []]; + const configVersion = NodeCrypto.createHash("sha256") + // Late editor discovery reconstructs objects in a different key + // order. The same config must still acknowledge a reconnect. + .update( + JSON.stringify(next, (_key, value: unknown) => + value !== null && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries( + Object.entries(value).sort(([left], [right]) => left.localeCompare(right)), + ) + : value, + ), + ) + .digest("hex"); + if (current === null) { + return [ + next, + [ + configVersion === knownVersion + ? { version: 1, type: "configUnchanged", configVersion } + : { version: 1, type: "snapshot", config: next, configVersion }, + ], + ]; + } + const delta = diffServerConfig(current, next, configVersion); + return [ + next, + Object.keys(delta.changes).length === 0 && + delta.removed.length === 0 && + delta.providerChanges.length === 0 && + delta.providerOrder === undefined + ? [] + : [delta], + ]; + }, + ), + ); + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -1734,23 +1805,42 @@ const layerWsRpc = ( ), ); - const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => + const loadServerConfig = (options: { + readonly usageLimitsCommand: boolean; + readonly deferEditors?: boolean; + readonly deferCatalogs?: boolean; + }) => Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; const currentProviders = yield* providerRegistry.getProviders; - const providers = options.usageLimitsCommand + const completeProviders = options.usageLimitsCommand ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) : currentProviders; + const providers = + options.deferCatalogs === true + ? completeProviders.map(({ workspaceSnapshots: _workspaces, ...provider }) => ({ + ...provider, + skills: [], + slashCommands: [], + })) + : completeProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; - const editorConfig = yield* resolveEditorConfig( - yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), - resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), - ); + const editorConfig = + options.deferEditors === true + ? { availableEditors: [] } + : yield* resolveEditorConfig( + yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ), + resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ), + ); return { environment, @@ -2460,7 +2550,8 @@ const layerWsRpc = ( : serverSettings.updateProviderInstance(providerInstanceMutation, nextPatch); return ServerSettings.redactServerSettingsForClient(settings); }), - [WS_METHODS.serverDiscoverSourceControl]: (_input) => sourceControlDiscovery.discover, + [WS_METHODS.serverDiscoverSourceControl]: (input) => + input.refresh === true ? sourceControlDiscovery.refresh : sourceControlDiscovery.discover, [WS_METHODS.serverGetTraceDiagnostics]: (_input) => TraceDiagnostics.readTraceDiagnostics({ traceFilePath: config.serverTracePath, @@ -3010,7 +3101,13 @@ const layerWsRpc = ( Stream.unwrap( Effect.gen(function* () { const usageLimitsCommand = input.usageLimitsCommand === true; - const config = yield* loadServerConfig({ usageLimitsCommand }); + const config = yield* loadServerConfig({ + usageLimitsCommand, + // Reconnects must match the previous late-editor version. + deferEditors: input.configDeltas === true && input.knownConfigVersion === undefined, + deferCatalogs: + input.configDeltas === true && input.knownConfigVersion === undefined, + }); yield* ServerActivation.markClientServed; const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ @@ -3103,10 +3200,13 @@ const layerWsRpc = ( ), ); - return Stream.concat( + const events = Stream.concat( rpcInitialItems([{ version: 1 as const, type: "snapshot" as const, config }]), withLateEditorConfig(config, liveUpdates, externalLauncher), ); + return input.configDeltas === true + ? withConfigDeltas(events, input.knownConfigVersion) + : events; }), ), [WS_METHODS.subscribeServerLifecycle]: (_input) => diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 1804051f6d1a0..d54994452e58e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1208,6 +1208,14 @@ export default function GitActionsControl({ }) : null, ); + const gitFilesQuery = useEnvironmentQuery( + isCommitDialogOpen && activeEnvironmentId !== null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: activeEnvironmentId, + input: { cwd: gitCwd, includeFiles: true }, + }) + : null, + ); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); @@ -1221,11 +1229,13 @@ export default function GitActionsControl({ // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; - const gitStatusForActions = gitStatus; + const gitStatusForActions = gitFilesQuery.data ?? gitStatus; // Matches the diff panel's Changes view. Older servers only report uncommitted totals. const changesTotals = gitStatusForActions?.branchChanges ?? gitStatusForActions?.workingTree; const allFiles = gitStatusForActions?.workingTree.files ?? []; + const areCommitFilesLoading = + gitFilesQuery.data === null && gitFilesQuery.isPending && allFiles.length === 0; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; @@ -2043,7 +2053,11 @@ export default function GitActionsControl({ )} - {!gitStatusForActions || allFiles.length === 0 ? ( + {gitFilesQuery.error !== null ? ( +

{gitFilesQuery.error}

+ ) : areCommitFilesLoading ? ( +

Loading files...

+ ) : !gitStatusForActions || allFiles.length === 0 ? (

none

) : (
diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 978d7a57105d0..91b3421902189 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -532,7 +532,7 @@ export function SourceControlSettingsPanel() { ? null : sourceControlEnvironment.discovery({ environmentId, - input: {}, + input: { refresh: true }, }), ); const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 38e1b7f89d31e..201cf44582a11 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -39,6 +39,7 @@ import * as RpcSession from "./session.ts"; import { makeEnvironmentServerConfigState } from "../state/server.ts"; import { applyServerConfigProjection } from "../state/serverConfigProjection.ts"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; +import { applyServerConfigDelta, diffServerConfig } from "@t3tools/shared/serverConfigDelta"; type SocketEventType = "open" | "message" | "close" | "error"; type SocketEvent = { @@ -262,7 +263,7 @@ const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialCo expect(request).toMatchObject({ _tag: "Request", tag: WS_METHODS.subscribeServerConfig, - payload, + payload: { ...(payload as object), configDeltas: true }, }); socket.serverMessage( encodeJson({ @@ -288,6 +289,120 @@ const publishConfigEvents = Effect.fn("TestRpcSessionFactory.publishConfigEvents }); describe("RpcSessionFactory", () => { + it("roundtrips provider deltas without resending catalogs and handles removal and order", () => { + const provider: ServerConfigType["providers"][number] = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T00:00:00.000Z", + models: [{ slug: "model", name: "Model", isCustom: false, capabilities: null }], + skills: [{ name: "skill", path: "/skill", enabled: true }], + slashCommands: [{ name: "compact" }], + displayName: "Custom name", + }; + const other = { ...provider, instanceId: ProviderInstanceId.make("other") }; + const previous = { ...SERVER_CONFIG, providers: [provider, other] }; + const { displayName: _name, ...withoutName } = provider; + const next = { + ...previous, + providers: [other, { ...withoutName, checkedAt: "2026-08-28T00:00:00.000Z" }], + }; + const delta = diffServerConfig(previous, next, "changed"); + expect(delta.providerChanges[0]?.changes.models).toBeUndefined(); + const wire = Schema.decodeSync(ServerConfigStreamEvent)(encodeServerConfigStreamEvent(delta)); + expect(wire.type).toBe("configDelta"); + if (wire.type !== "configDelta") return; + expect(applyServerConfigDelta(previous, wire)).toEqual(next); + const removed = { ...next, providers: [next.providers[1]!] }; + expect(applyServerConfigDelta(next, diffServerConfig(next, removed, "removed"))).toEqual( + removed, + ); + expect(applyServerConfigDelta(removed, diffServerConfig(removed, previous, "added"))).toEqual( + previous, + ); + }); + + it.effect("deduplicates discovery calls and invalidates on explicit refresh", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* session.ready; + const discover = session.client[WS_METHODS.serverDiscoverSourceControl]; + const first = yield* Effect.all([discover({}), discover({})], { concurrency: 2 }).pipe( + Effect.forkChild, + ); + const reply = (request: typeof RpcRequest.Type) => + socket.serverMessage( + encodeJson({ + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Success", + value: { versionControlSystems: [], sourceControlProviders: [] }, + }, + }), + ); + reply(yield* awaitRequest(socket, 1)); + expect(yield* Fiber.join(first)).toHaveLength(2); + yield* discover({}); + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 2, + ); + const refreshed = yield* discover({ refresh: true }).pipe(Effect.forkChild); + const request = yield* awaitRequest(socket, 2); + expect(request.payload).toEqual({ refresh: true }); + reply(request); + yield* Fiber.join(refreshed); + const next = yield* discover({}).pipe(Effect.forkChild); + reply(yield* awaitRequest(socket, 3)); + yield* Fiber.join(next); + }), + ); + + it.effect("restores only acknowledged config versions and applies later deltas", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* factory.connect(PREPARED); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* publishConfigEvents(socket, [ + { version: 1, type: "snapshot", config: SERVER_CONFIG, configVersion: "first" }, + ]); + yield* session.ready; + const next = { ...SERVER_CONFIG, cwd: "/changed" }; + const delta = diffServerConfig(SERVER_CONFIG, next, "second"); + expect(applyServerConfigDelta(SERVER_CONFIG, delta)).toEqual(next); + const observed = yield* Queue.unbounded(); + yield* session.subscribeServerConfig({}).pipe( + Stream.runForEach((event) => Queue.offer(observed, event)), + Effect.forkScoped, + ); + expect((yield* Queue.take(observed)).type).toBe("snapshot"); + yield* publishConfigEvents(socket, [delta]); + expect((yield* Queue.take(observed)).type).toBe("configDelta"); + }), + ); + const session = yield* factory.connect(PREPARED); + const socket = yield* awaitSocket(sockets, 1); + socket.open(); + const request = yield* awaitRequest(socket); + expect(request.payload).toEqual({ configDeltas: true, knownConfigVersion: "second" }); + yield* publishConfigEvents(socket, [ + { version: 1, type: "configUnchanged", configVersion: "second" }, + ]); + yield* session.ready; + expect((yield* session.initialConfig).cwd).toBe("/changed"); + }), + ); it.effect("owns one scoped websocket attempt and exposes readiness and closure", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 9f83e7c6019dc..d91555d941a44 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -158,6 +158,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }; + const configCache = new Map(); const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { const networkHint = @@ -217,21 +218,52 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( const protocolContext = yield* Layer.build(layerProtocol).pipe( Effect.withSpan("environment.websocket.connect"), ); - const protocolClient = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const rawClient = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const [discoverSourceControl, invalidateDiscovery] = yield* Effect.cachedInvalidateWithTTL( + rawClient[WS_METHODS.serverDiscoverSourceControl]({}), + "30 seconds", + ); + type DiscoveryMethod = WsRpcProtocolClient[typeof WS_METHODS.serverDiscoverSourceControl]; + const discover = ( + input: Parameters[0], + options?: Parameters[1], + ) => + options !== undefined + ? rawClient[WS_METHODS.serverDiscoverSourceControl](input, options) + : input.refresh === true + ? invalidateDiscovery.pipe( + Effect.andThen(rawClient[WS_METHODS.serverDiscoverSourceControl](input)), + ) + : discoverSourceControl; + const protocolClient: WsRpcProtocolClient = { + ...rawClient, + // Options retain the raw generic discard/header behavior. Only calls + // without options use the shared ordinary response. + [WS_METHODS.serverDiscoverSourceControl]: discover as DiscoveryMethod, + }; const initialConfigDeferred = yield* Deferred.make(); const serverConfigExit = yield* Deferred.make(); const configSubscriptionClosed = yield* Deferred.make(); + const cachedConfig = configCache.get(connection.environmentId); const serverConfigState = yield* Ref.make(Option.none()); const serverConfigUpdates = yield* PubSub.sliding(64); const configSubscriptionEndedError = new ConnectionTransientErrorClass({ reason: "remote-unavailable", detail: `${connection.label} config subscription ended.`, }); - const serverConfigSource = protocolClient[WS_METHODS.subscribeServerConfig]( - serverConfigInput, - ).pipe( - Stream.runForEach((event) => + const serverConfigSource = protocolClient[WS_METHODS.subscribeServerConfig]({ + ...serverConfigInput, + configDeltas: true, + ...(cachedConfig === undefined ? {} : { knownConfigVersion: cachedConfig.version }), + }).pipe( + Stream.runForEach((wireEvent) => Effect.gen(function* () { + // Restore only after this server acknowledges the cache. Older + // servers ignore the opt-in and send their ordinary full snapshot. + const event = + wireEvent.type === "configUnchanged" && cachedConfig !== undefined + ? { version: 1 as const, type: "snapshot" as const, config: cachedConfig.config } + : wireEvent; const buffered = yield* Ref.modify(serverConfigState, (current) => { const projection = applyServerConfigProjection( Option.map(current, (state) => state.projection), @@ -267,10 +299,19 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( ] as const; }); if (Option.isSome(buffered)) { + if ("configVersion" in wireEvent && wireEvent.configVersion !== undefined) { + configCache.delete(connection.environmentId); + configCache.set(connection.environmentId, { + config: withoutEnvironmentThemes(buffered.value.replay.projection.config), + version: wireEvent.configVersion, + }); + if (configCache.size > 32) configCache.delete(configCache.keys().next().value!); + } else if (wireEvent.type === "snapshot") { + // A downgraded environment must not keep a newer wire version. + configCache.delete(connection.environmentId); + } yield* PubSub.publish(serverConfigUpdates, buffered.value); - } - if (event.type === "snapshot") { - yield* Deferred.succeed(initialConfigDeferred, event.config); + yield* Deferred.succeed(initialConfigDeferred, buffered.value.replay.projection.config); } }), ), diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts index 77883980332a0..3c1e5c68af5a2 100644 --- a/packages/client-runtime/src/state/serverConfigProjection.ts +++ b/packages/client-runtime/src/state/serverConfigProjection.ts @@ -1,4 +1,5 @@ import type { ServerConfig, ServerConfigStreamEvent } from "@t3tools/contracts"; +import { applyServerConfigDelta } from "@t3tools/shared/serverConfigDelta"; import * as Option from "effect/Option"; export interface ServerConfigProjection { @@ -26,6 +27,18 @@ export function applyServerConfigProjection( event: ServerConfigStreamEvent, ): Option.Option { switch (event.type) { + case "configUnchanged": + return Option.map(current, (projection) => ({ + ...projection, + latestEvent: event, + source: "live", + })); + case "configDelta": + return Option.map(current, (projection) => ({ + config: applyServerConfigDelta(projection.config, event), + latestEvent: event, + source: "live", + })); case "snapshot": { // Wire snapshots never contain published themes. Keep the previous set // until a capable server sends its authoritative theme event. A legacy diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index c319e3e3ad1ff..f6b596a7a8f30 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom, AtomRegistry } from "effect/reactivity"; @@ -24,7 +25,7 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import * as Persistence from "../platform/persistence.ts"; -import { request, subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { request, subscribeDynamic, type EnvironmentRpcInput } from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; import { @@ -243,6 +244,9 @@ function cachedVcsRefsChanges( export function createVcsEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { + // Replay survives atom disposal without extending the stream lifetime. + const statuses = new Map(); + const encodeKey = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); /** * One flat family on purpose: families hold entries via WeakRef, so a nested * per-environment family can be collected between lookups, dropping every @@ -284,14 +288,38 @@ export function createVcsEnvironmentAtoms( label: "environment-data:vcs:status", idleTtlMs: VCS_STATUS_IDLE_TTL_MS, subscribe: (input: EnvironmentRpcInput) => - subscribe(WS_METHODS.subscribeVcsStatus, input).pipe( - Stream.mapAccum( - () => null as VcsStatusResult | null, - (current, event) => { - const next = applyGitStatusStreamEvent(current, event); - return [next, [next]] as const; - }, - ), + Stream.unwrap( + Effect.gen(function* () { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const key = encodeKey([supervisor.target.environmentId, input]); + const cached = statuses.get(key); + let current = cached?.status ?? null; + const changes = subscribeDynamic(WS_METHODS.subscribeVcsStatus, () => + Effect.sync(() => { + const knownVersion = statuses.get(key)?.version; + return { + ...input, + compact: true, + ...(knownVersion === undefined ? {} : { knownVersion }), + }; + }), + ).pipe( + Stream.filterMap((event) => { + if (event._tag === "unchanged") return Result.failVoid; + current = applyGitStatusStreamEvent(current, event); + statuses.delete(key); + statuses.set(key, { + status: current, + version: event._tag === "snapshot" ? event.version : undefined, + }); + if (statuses.size > 128) statuses.delete(statuses.keys().next().value!); + return Result.succeed(current); + }), + ); + return cached === undefined + ? changes + : Stream.concat(Stream.succeed(cached.status), changes); + }), ), }), pull: createEnvironmentRpcCommand(runtime, { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index b2cb2e902d8e7..88edb2caaa920 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -115,6 +115,10 @@ export const VcsStatusSubscriptionInput = Schema.Struct({ ...VcsStatusInput.fields, /** Passive observers receive cached remote status without retaining its refresh loop. */ includeRemote: Schema.optional(Schema.Boolean), + /** Opt-in summary/ETag stream. Older servers ignore these fields. */ + compact: Schema.optionalKey(Schema.Boolean), + includeFiles: Schema.optionalKey(Schema.Boolean), + knownVersion: Schema.optionalKey(TrimmedNonEmptyStringSchema), }); export type VcsStatusSubscriptionInput = typeof VcsStatusSubscriptionInput.Type; @@ -232,6 +236,8 @@ const VcsStatusLocalShape = { refName: Schema.NullOr(TrimmedNonEmptyStringSchema), hasWorkingTreeChanges: Schema.Boolean, workingTree: Schema.Struct({ + /** Present on summaries, whose file list is intentionally omitted. */ + fileCount: Schema.optionalKey(NonNegativeInt), files: Schema.Array( Schema.Struct({ path: TrimmedNonEmptyStringSchema, @@ -279,7 +285,9 @@ export const VcsStatusStreamEvent = Schema.Union([ Schema.TaggedStruct("snapshot", { local: VcsStatusLocalResult, remote: Schema.NullOr(VcsStatusRemoteResult), + version: Schema.optionalKey(TrimmedNonEmptyStringSchema), }), + Schema.TaggedStruct("unchanged", { version: TrimmedNonEmptyStringSchema }), Schema.TaggedStruct("localUpdated", { local: VcsStatusLocalResult, }), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 36afee2f9efa0..9c3e33fd9142c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -765,7 +765,7 @@ const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { }); const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { - payload: Schema.Struct({}), + payload: Schema.Struct({ refresh: Schema.optionalKey(Schema.Boolean) }), success: SourceControlDiscoveryResult, error: EnvironmentAuthorizationError, }); @@ -1713,6 +1713,9 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon * client would send it to the provider as an ordinary prompt. */ usageLimitsCommand: Schema.optional(Schema.Boolean), + /** Opt-in config deltas and conditional reconnect snapshots. */ + configDeltas: Schema.optionalKey(Schema.Boolean), + knownConfigVersion: Schema.optionalKey(TrimmedNonEmptyString), }), success: ServerConfigStreamEvent, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5855477855d19..990016778fac7 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; import { AcpRegistryUrlAuthAction } from "./acpRegistry.ts"; import { type EnvironmentMachineKind, @@ -789,6 +790,7 @@ export const ServerConfigStreamSnapshotEvent = Schema.Struct({ version: Schema.Literal(1), type: Schema.Literal("snapshot"), config: ServerConfig, + configVersion: Schema.optionalKey(TrimmedNonEmptyString), }); export type ServerConfigStreamSnapshotEvent = typeof ServerConfigStreamSnapshotEvent.Type; @@ -853,6 +855,31 @@ export const ServerConfigStreamEvent = Schema.Union([ ServerConfigStreamSettingsUpdatedEvent, ServerConfigStreamEnvironmentThemesUpdatedEvent, ServerConfigStreamUsageLimitSourcesUpdatedEvent, + Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("configUnchanged"), + configVersion: TrimmedNonEmptyString, + }), + Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("configDelta"), + configVersion: TrimmedNonEmptyString, + changes: ServerConfig.mapFields(Struct.map(Schema.optionalKey)), + removed: Schema.Array(Schema.String), + providerChanges: Schema.Array( + Schema.Struct({ + instanceId: ProviderInstanceId, + // Snapshot defaults must not turn absent patch fields into clears. + changes: Schema.Struct({ + ...ServerProvider.mapFields(Struct.map(Schema.optionalKey)).fields, + slashCommands: Schema.optionalKey(Schema.Array(ServerProviderSlashCommand)), + skills: Schema.optionalKey(Schema.Array(ServerProviderSkill)), + }), + removed: Schema.Array(Schema.String), + }), + ), + providerOrder: Schema.optionalKey(Schema.Array(ProviderInstanceId)), + }), ]); export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 9130bc8383952..a1c75dad18b4c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./serverConfigDelta": { + "types": "./src/serverConfigDelta.ts", + "import": "./src/serverConfigDelta.ts" + }, "./authScopeOptions": { "types": "./src/authScopeOptions.ts", "import": "./src/authScopeOptions.ts" diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 6efef3f70ac3c..9a0ff8f31b5ef 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -399,6 +399,9 @@ export function applyGitStatusStreamEvent( event: VcsStatusStreamEvent, ): VcsStatusResult { switch (event._tag) { + case "unchanged": + if (current === null) throw new Error("VCS version requires a cached status."); + return current; case "snapshot": return mergeGitStatusParts(event.local, event.remote); case "localUpdated": diff --git a/packages/shared/src/serverConfigDelta.ts b/packages/shared/src/serverConfigDelta.ts new file mode 100644 index 0000000000000..7da1deddedf96 --- /dev/null +++ b/packages/shared/src/serverConfigDelta.ts @@ -0,0 +1,72 @@ +import { ServerConfig, type ServerConfigStreamEvent } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +type ConfigDelta = Extract; +const decodeConfig = Schema.decodeUnknownSync(Schema.toType(ServerConfig)); + +function changedFields(previous: T, next: T) { + const changes: Partial = {}; + const removed: string[] = []; + for (const key of Object.keys(next) as Array) { + if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) changes[key] = next[key]; + } + for (const key of Object.keys(previous)) { + if (!(key in next)) removed.push(key); + } + return { changes, removed }; +} + +/** Provider catalogs stay on the client when only health or quota changes. */ +export function diffServerConfig( + previous: ServerConfig, + next: ServerConfig, + configVersion: string, +): ConfigDelta { + const { providers: _previous, ...previousFields } = previous; + const { providers: _next, ...nextFields } = next; + const fields = changedFields(previousFields, nextFields); + const previousProviders = new Map( + previous.providers.map((provider) => [provider.instanceId, provider]), + ); + const providerChanges = next.providers.flatMap((provider) => { + const current = previousProviders.get(provider.instanceId); + const patch = + current === undefined ? { changes: provider, removed: [] } : changedFields(current, provider); + return Object.keys(patch.changes).length === 0 && patch.removed.length === 0 + ? [] + : [{ instanceId: provider.instanceId, ...patch }]; + }); + const providerOrder = next.providers.map((provider) => provider.instanceId); + return { + version: 1, + type: "configDelta", + configVersion, + ...fields, + providerChanges, + ...(JSON.stringify(previous.providers.map((provider) => provider.instanceId)) === + JSON.stringify(providerOrder) + ? {} + : { providerOrder }), + }; +} + +/** Validate the reconstructed config, including newly added providers and removals. */ +export function applyServerConfigDelta(current: ServerConfig, event: ConfigDelta): ServerConfig { + const config = { ...current, ...event.changes }; + for (const key of event.removed) Reflect.deleteProperty(config, key); + const providers = new Map(current.providers.map((provider) => [provider.instanceId, provider])); + for (const patch of event.providerChanges) { + const provider = { ...providers.get(patch.instanceId), ...patch.changes }; + for (const key of patch.removed) Reflect.deleteProperty(provider, key); + // Decoded together below so the config's forward-compatible provider array + // retains its existing behavior for unknown provider states. + providers.set(patch.instanceId, provider as ServerConfig["providers"][number]); + } + return decodeConfig({ + ...config, + providers: + event.providerOrder === undefined + ? [...providers.values()] + : event.providerOrder.map((id) => providers.get(id)), + }); +} From 911bf7541a22bca745b8fcdeeb0dfe987fdb924f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 00:50:22 +0000 Subject: [PATCH 018/241] perf(web): keep thread views mounted across switches and full pages Switching threads re-rendered one shared ChatView for the new thread inside the click, blocking input for 200-650 ms and restoring the scroll position from an estimate. The last four threads now stay rendered inside hidden Activity boundaries and a revisit only reveals them, at the same scroll position. The swap runs in a transition, so the URL and sidebar selection paint with the click. The thread surface is hosted by the root layout once the chat route loads, and the thread sidebar is hidden rather than unmounted on settings, so returning from settings or pull requests is a reveal too. The composer editor survives a hide with its undo history, and ref-held elements are not cleared while their view is only hidden, which avoids re-measuring the view. A thread that is still loading shows a spinner instead of a blank pane. --- apps/web/src/components/AppSidebarLayout.tsx | 21 +- apps/web/src/components/ChatView.logic.ts | 2 +- apps/web/src/components/ChatView.tsx | 31 ++- .../components/ComposerPromptEditorTiptap.tsx | 9 +- apps/web/src/components/ThreadRouteView.tsx | 184 +++++++++++---- apps/web/src/components/chat/ChatCanvas.tsx | 4 +- apps/web/src/components/chat/ChatComposer.tsx | 12 +- .../src/components/chat/MessagesTimeline.tsx | 19 +- apps/web/src/components/chatSurfaceSlot.tsx | 28 +++ apps/web/src/components/keptView.tsx | 221 ++++++++++++++++++ apps/web/src/hooks/useElementState.ts | 31 +++ apps/web/src/routes/__root.tsx | 2 + .../routes/_chat.$environmentId.$threadId.tsx | 2 +- apps/web/src/routes/_chat.draft.$draftId.tsx | 2 +- apps/web/src/routes/_chat.tsx | 19 +- apps/web/src/threadRoutes.ts | 99 +++++++- 16 files changed, 591 insertions(+), 95 deletions(-) create mode 100644 apps/web/src/components/chatSurfaceSlot.tsx create mode 100644 apps/web/src/components/keptView.tsx create mode 100644 apps/web/src/hooks/useElementState.ts diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 43930af3a4227..0b63a5cc0e6e4 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { + Activity, lazy, Suspense, useEffect, @@ -236,7 +237,7 @@ function NavigationHistoryShortcuts() { return null; } -// Settings swaps the thread sidebar out of the tree. Keep the lightweight +// Settings hides the thread sidebar, which drops its subscriptions. Keep the lightweight // project projection subscribed so returning to a draft never renders the // zero-project state while the environment snapshot reconnects. function ProjectProjectionRetention() { @@ -358,13 +359,17 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { - ) : legacySidebarEnabled ? ( - }> - - - ) : ( - - )} + ) : null} + {/* Hidden rather than unmounted on settings, so going back only reveals it. */} + + {legacySidebarEnabled ? ( + }> + + + ) : ( + + )} + {children} diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 086303a58dc9f..b40c1fe158ce0 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -426,7 +426,7 @@ export function resolveThreadSwitchTimeline(input: return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey }; } - const lastReady = input.lastReady ?? peekHeldThreadTimeline(); + const lastReady = input.lastReady === undefined ? peekHeldThreadTimeline() : input.lastReady; if ( input.loading && lastReady !== null && diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e92d3349125a2..35b173199ea01 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -242,6 +242,7 @@ import { isCommandPaletteOpen } from "../commandPaletteBus"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { useElementState } from "../hooks/useElementState"; import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, @@ -1835,18 +1836,22 @@ export default function ChatView(props: ChatViewProps) { ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); + // A string, and null for every location without this thread's citation, so + // ordinary navigation does not re-render the view. const citationLocation = useLocation({ - select: (location) => ({ - href: location.href, - key: location.state.assistantCitationActivation ?? location.state.__TSR_key, - }), + select: (location) => { + const citation = assistantCitationFromLocation(location.href); + if (citation?.environmentId !== environmentId || citation.threadId !== threadId) return null; + const key = location.state.assistantCitationActivation ?? location.state.__TSR_key; + return `${key ?? location.href}\u0000${location.href}`; + }, }); const citationRequest = useMemo(() => { - const citation = assistantCitationFromLocation(citationLocation.href); - return citation && citation.environmentId === environmentId && citation.threadId === threadId - ? { citation, key: citationLocation.key ?? citationLocation.href } - : null; - }, [citationLocation.href, citationLocation.key, environmentId, threadId]); + if (citationLocation === null) return null; + const separator = citationLocation.indexOf("\u0000"); + const citation = assistantCitationFromLocation(citationLocation.slice(separator + 1)); + return citation ? { citation, key: citationLocation.slice(0, separator) } : null; + }, [citationLocation]); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( @@ -1901,7 +1906,7 @@ export default function ChatView(props: ChatViewProps) { const branchToolbarRef = useRef(null); const pasteAsTextShortcutUntilRef = useRef(0); const [restingComposerControlsHost, setRestingComposerControlsHost] = - useState(null); + useElementState(); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); const citeAssistantText = useCallback( (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => { @@ -2025,7 +2030,7 @@ export default function ChatView(props: ChatViewProps) { () => legendListRef.current?.getScrollableNode() ?? null, [], ); - const [composerOverlayElement, setComposerOverlayElement] = useState(null); + const [composerOverlayElement, setComposerOverlayElement] = useElementState(); // Space the timeline keeps clear above its end. Tracks the overlay while the // composer is expanded and holds that height while it rests, so the resting // composer never exposes rows that its expansion will cover. @@ -4021,6 +4026,9 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, nextEntries: timelineEntries, rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), + // Each thread has its own view (see ThreadRouteView), so another thread's + // timeline is never painted while this one loads. + lastReady: null, }); const displayedTimelineKey = displayedTimeline.displayThreadKey ?? routeThreadKey; const paintOnlyDisplayedTimeline = isPaintOnlyThreadTimeline( @@ -11608,6 +11616,7 @@ export default function ChatView(props: ChatViewProps) { onManualNavigation={cancelTimelineLiveFollowForUserNavigation} cancelPositionRestoreRef={cancelPositionRestoreRef} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} + loading={threadDetailLoading && !isDraftHeroState} topFadeEnabled={!hasTimelineTopBanner} {...(paintOnlyDisplayedTimeline || threadHistoryControls === undefined ? {} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 22002431097fa..23cfb0021a00f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -38,7 +38,8 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; -import { EditorContent, useEditor } from "@tiptap/react"; +import type { Editor } from "@tiptap/core"; +import { KeptEditorContent, useKeptEditor } from "./keptView"; import { clampCollapsedComposerCursor, @@ -772,7 +773,7 @@ const ComposerMarkersExtension = Extension.create({ // unit tests can round-trip it without a browser. // ── Editor component ─────────────────────────────────────────────────────── -type TiptapEditor = NonNullable>; +type TiptapEditor = Editor; export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { // Extensions are creation-time, so changing text mode remounts the editor. @@ -1022,7 +1023,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { [activeSuggestionId, ariaLabel, className, disabled, placeholder, richText, suggestionListId], ); - const editor = useEditor( + const editor = useKeptEditor( { extensions: [ StarterKit.configure({ @@ -1745,7 +1746,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { containerClassName, )} > - { if ( diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 934feac61a839..49f98170488ed 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -1,9 +1,19 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import { useNavigate } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { + Activity, + memo, + useDeferredValue, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import ChatView from "./ChatView"; +import { createKeptViewLifetime, KeptViewLifetimeContext } from "./keptView"; import { resolveDraftPromotionNavigationTarget, threadHasStarted } from "./ChatView.logic"; import { waitForDraftHeroTransition } from "./chat/draftHeroTransition"; import { SidebarInset } from "./ui/sidebar"; @@ -19,10 +29,18 @@ import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; import { buildThreadRouteParams, + resolveKeptThreadViews, resolveThreadRouteRenderState, + resolveThreadRouteTarget, + threadRouteTargetFromKey, + threadRouteTargetKey, + type KeptThreadView, type ThreadRouteTarget, } from "../threadRoutes"; +// Kept views re-render only when their own thread or route kind changes. +const KeptChatView = memo(ChatView); + /** * The single chat surface behind both `/draft/$draftId` and * `/$environmentId/$threadId`. Each draft gets its own ChatView instance (so @@ -30,13 +48,21 @@ import { * instance carries the draft through its promotion to a server thread: the * thread route keeps keying by the draft id while the draft record exists, * so the route swap only changes props and the timeline never paints an - * empty frame. Plain server threads are unkeyed, so navigating between them - * reuses one instance as ChatView expects. + * empty frame. + * + * The last few server threads stay rendered inside hidden `` + * boundaries, so a revisit only reveals the view it already painted. Hidden + * views keep their DOM and state; React disconnects their effects and store + * subscriptions until they are shown again. * - * Rendered by the `_chat` layout rather than by the two leaf routes, since - * an element only survives a route swap when the same parent renders it. + * Rendered by KeptChatSurface rather than by the two leaf routes, since an + * element only survives a route swap when the same parent renders it. */ -export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { +export const ThreadRouteView = memo(function ThreadRouteView({ + target, +}: { + target: ThreadRouteTarget; +}) { const navigate = useNavigate(); const draftId = target.kind === "draft" ? target.draftId : null; const draftSession = useComposerDraftStore((store) => @@ -76,25 +102,6 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { const draftThread = useComposerDraftStore((store) => serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, ); - const promotedDraftId = useComposerDraftStore((store) => - target.kind === "server" ? store.getDraftIdByRef(target.threadRef) : null, - ); - // The draft record is removed once the promoted thread has started, which - // is after the route swap. Latch the key so the element that carried the - // draft keeps its identity for as long as this thread stays on screen. - const [chatViewKey, setChatViewKey] = useState<{ threadKey: string; key: string } | null>(null); - const serverThreadKey = target.kind === "server" ? scopedThreadKey(target.threadRef) : null; - const nextChatViewKey = - serverThreadKey === null - ? null - : chatViewKey?.threadKey === serverThreadKey - ? chatViewKey - : promotedDraftId - ? { threadKey: serverThreadKey, key: promotedDraftId } - : null; - if (nextChatViewKey !== chatViewKey) { - setChatViewKey(nextChatViewKey); - } const environmentHasDraftThreads = useComposerDraftStore((store) => serverThreadRef ? store.hasDraftThreadsInEnvironment(serverThreadRef.environmentId) : false, ); @@ -164,34 +171,119 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { finalizePromotedDraftThreadByRef(target.threadRef); }, [draftThread, serverThreadStarted, target]); - let view: React.ReactNode = null; + let activeView: Omit | null = null; if (target.kind === "draft") { if (draftSession) { - view = ( - - ); + activeView = { + key: target.draftId, + threadKey: scopedThreadKey(draftSession), + environmentId: draftSession.environmentId, + threadId: draftSession.threadId, + draftId: target.draftId, + }; } } else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) { - view = ( - - ); + activeView = { + key: scopedThreadKey(target.threadRef), + threadKey: scopedThreadKey(target.threadRef), + environmentId: target.threadRef.environmentId, + threadId: target.threadRef.threadId, + draftId: null, + }; + } + const [keptViews, setKeptViews] = useState>([]); + const nextKeptViews = resolveKeptThreadViews(keptViews, activeView); + if (nextKeptViews !== keptViews) { + setKeptViews(nextKeptViews); } + // One lifetime per kept view, released once the view is gone for good. + const lifetimesRef = useRef(new Map>()); + const pendingUnmountDisposeRef = useRef | null>(null); + const lifetimeFor = (key: string) => { + let lifetime = lifetimesRef.current.get(key); + if (lifetime === undefined) { + lifetime = createKeptViewLifetime(); + lifetimesRef.current.set(key, lifetime); + } + return lifetime; + }; + useLayoutEffect(() => { + const keys = new Set(keptViews.map((view) => view.key)); + for (const [key, lifetime] of lifetimesRef.current) { + if (keys.has(key)) continue; + lifetimesRef.current.delete(key); + lifetime.dispose(); + } + }, [keptViews]); + useEffect(() => { + const lifetimes = lifetimesRef.current; + if (pendingUnmountDisposeRef.current !== null) clearTimeout(pendingUnmountDisposeRef.current); + return () => { + // A task later, so StrictMode's simulated remount can cancel it. + pendingUnmountDisposeRef.current = setTimeout(() => { + for (const lifetime of lifetimes.values()) lifetime.dispose(); + lifetimes.clear(); + }, 0); + }; + }, []); + const activeViewKey = + activeView === null + ? null + : (nextKeptViews.find((view) => + activeView.draftId === null + ? view.threadKey === activeView.threadKey + : view.key === activeView.key, + )?.key ?? null); return ( - {view} + {nextKeptViews.map((view) => ( + + + {view.draftId === null ? ( + + ) : ( + + )} + + + ))} ); +}); + +/** + * Hosts the thread view above the route outlet. A full page (settings, pull + * requests) hides the chat rather than unmounting it, so returning to a + * thread only reveals it. + */ +export function KeptChatSurface() { + const targetKey = useParams({ + strict: false, + select: (params) => threadRouteTargetKey(resolveThreadRouteTarget(params)), + }); + // The URL and sidebar selection commit with the click; the thread view + // swap follows in a transition, so opening a thread never holds that frame. + const deferredTargetKey = useDeferredValue(targetKey); + const [shownTargetKey, setShownTargetKey] = useState(deferredTargetKey); + if (deferredTargetKey !== null && deferredTargetKey !== shownTargetKey) { + setShownTargetKey(deferredTargetKey); + } + const target = useMemo(() => threadRouteTargetFromKey(shownTargetKey), [shownTargetKey]); + if (target === null) return null; + return ( + + + + ); } diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 35f9665658519..7a8d7747ee114 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -7,6 +7,8 @@ import { type ComponentProps, type CSSProperties, } from "react"; +import { flushSync } from "react-dom"; +import { useElementState } from "../../hooks/useElementState"; import { ChatCanvasContext } from "./ChatCanvasContext"; import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; @@ -27,7 +29,7 @@ export function ChatCanvas({ }) { const elementRef = useRef(null); const widthProbeRef = useRef(null); - const [timelineElement, registerTimeline] = useState(null); + const [timelineElement, registerTimeline] = useElementState(); const [preview, setPreview] = useState(null); const [detailsCard, setDetailsCard] = useState(null); const reportDetailsCard = useCallback((next: PreviewMiniPlayerObstacles["detailsCard"]) => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0d55c1c83e067..e9911a2c0deb6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1131,6 +1131,7 @@ import { import { searchProviderSkills } from "../../providerSkillSearch"; import { useDelayedStatus } from "../../hooks/useDelayedStatus"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useElementState } from "../../hooks/useElementState"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; import { readEnvironmentScope } from "../../state/session"; @@ -1151,7 +1152,7 @@ const extendReplacementRangeForTrailingSpace = ( }; function useRestingComposerControlsLayout(host: HTMLDivElement | null, useControlsAsHost = false) { - const [controls, setControls] = useState(null); + const [controls, setControls] = useElementState(); const controlsRef = useRef(null); const attachControls = useCallback((element: HTMLDivElement | null) => { controlsRef.current = element; @@ -2452,7 +2453,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, ); - const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); + const [composerMenuAnchor, setComposerMenuAnchor] = useElementState(); const hasWrappedPrompt = useComposerMultilinePrompt(composerMenuAnchor); const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt; const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); @@ -2999,9 +3000,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerOwned: true, } satisfies Parameters[0]; const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput); - const [inlineRestingControlsHost, setInlineRestingControlsHost] = useState( - null, - ); + const [inlineRestingControlsHost, setInlineRestingControlsHost] = + useElementState(); const { controlsRef: restingComposerControlsRef, attachControls: attachRestingComposerControls, @@ -5243,7 +5243,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [isComposerResting, onRestingChange]); // The resting footer floats over the prompt row, so the row reserves its // measured width. Wide actions like the compact-before-send chip vary too much for a fixed inset. - const [restingActionsElement, setRestingActionsElement] = useState(null); + const [restingActionsElement, setRestingActionsElement] = useElementState(); const [restingActionsWidth, setRestingActionsWidth] = useState(null); useLayoutEffect(() => { if (!isComposerResting || !restingActionsElement) return; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ef0bb61ee09cf..94a7491b56580 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -265,6 +265,8 @@ import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { useClientSettings } from "~/hooks/useSettings"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { useElementState } from "~/hooks/useElementState"; +import { useDelayedStatus } from "~/hooks/useDelayedStatus"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -529,6 +531,8 @@ interface MessagesTimelineProps { findOpen?: boolean; cancelPositionRestoreRef?: React.RefObject<(() => void) | null> | undefined; hideEmptyPlaceholder?: boolean; + /** The thread's messages are still loading; the empty pane shows a spinner once that lasts. */ + loading?: boolean; topFadeEnabled?: boolean; historyControls?: MessagesTimelineHistoryControls | undefined; /** Non-null when older turns exist beyond the loaded window. */ @@ -625,11 +629,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ onManualNavigation, cancelPositionRestoreRef, hideEmptyPlaceholder = false, + loading = false, topFadeEnabled = false, historyControls, loadEarlier = null, }: MessagesTimelineProps) { const listIdentityKey = displayThreadKey ?? routeThreadKey; + const shownLoading = useDelayedStatus(routeThreadKey, loading ? true : null); const rememberedPosition = useMemo( () => readTimelinePosition(listIdentityKey), [listIdentityKey], @@ -1045,9 +1051,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ rows, ]); - const [timelineViewportElement, setTimelineViewportElement] = useState( - null, - ); + const [timelineViewportElement, setTimelineViewportElement] = useElementState(); // Re-measure the minimap gutter when the chat column changes width without a viewport resize. const chatWidth = useClientSettings((settings) => settings.chatWidth); const { @@ -1556,7 +1560,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ if (hideEmptyPlaceholder) { // Occupy the pane with the theme surface so a thread switch cannot // punch a hole through to the window chrome (white in light mode). - return
; + return ( +
+ {shownLoading ? : null} +
+ ); } return (
diff --git a/apps/web/src/components/chatSurfaceSlot.tsx b/apps/web/src/components/chatSurfaceSlot.tsx new file mode 100644 index 0000000000000..214252e9aaa27 --- /dev/null +++ b/apps/web/src/components/chatSurfaceSlot.tsx @@ -0,0 +1,28 @@ +import { useSyncExternalStore, type ComponentType } from "react"; + +let surface: ComponentType | null = null; +const listeners = new Set<() => void>(); + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * The chat layout registers its thread surface here the first time it + * renders. The root layout keeps rendering it from then on, so leaving the + * chat for a full page hides the thread view instead of unmounting it, while + * routes that never open a thread do not load the chat code. + */ +export function registerChatSurface(component: ComponentType) { + if (surface === component) return; + surface = component; + for (const listener of listeners) listener(); +} + +export function ChatSurfaceSlot() { + const Surface = useSyncExternalStore(subscribe, () => surface); + return Surface ? : null; +} diff --git a/apps/web/src/components/keptView.tsx b/apps/web/src/components/keptView.tsx new file mode 100644 index 0000000000000..d3bcd9c61b296 --- /dev/null +++ b/apps/web/src/components/keptView.tsx @@ -0,0 +1,221 @@ +import { Editor, type EditorOptions } from "@tiptap/core"; +import { PureEditorContent, type EditorContentProps } from "@tiptap/react"; +import { + createContext, + forwardRef, + memo, + useContext, + useEffect, + useMemo, + useReducer, + useRef, + useState, + type DependencyList, + type RefObject, +} from "react"; + +/** + * A thread view that `ThreadRouteView` keeps rendered while hidden. React + * runs a hidden view's effect cleanups without unmounting it, so work that + * should survive the hide holds its release here; it runs when the view is + * evicted or unmounted for good. + */ +export interface KeptViewLifetime { + /** Runs `release` when the kept view goes away. Returns a function that cancels it. */ + hold(release: () => void): () => void; +} + +export const KeptViewLifetimeContext = createContext(null); + +export function createKeptViewLifetime(): KeptViewLifetime & { dispose(): void } { + const held = new Set<() => void>(); + let disposed = false; + return { + hold(release) { + if (disposed) { + release(); + return () => undefined; + } + held.add(release); + return () => { + held.delete(release); + }; + }, + dispose() { + disposed = true; + const releases = [...held]; + held.clear(); + for (const release of releases) release(); + }, + }; +} + +type EditorCallbackKey = + | "onBeforeCreate" + | "onCreate" + | "onUpdate" + | "onSelectionUpdate" + | "onTransaction" + | "onFocus" + | "onBlur" + | "onDestroy" + | "onContentError" + | "onDrop" + | "onPaste" + | "onDelete" + | "onMount" + | "onUnmount"; + +const EDITOR_CALLBACK_KEYS: ReadonlyArray = [ + "onBeforeCreate", + "onCreate", + "onUpdate", + "onSelectionUpdate", + "onTransaction", + "onFocus", + "onBlur", + "onDestroy", + "onContentError", + "onDrop", + "onPaste", + "onDelete", + "onMount", + "onUnmount", +]; + +function forwardCallback( + options: RefObject>, + key: K, +): EditorOptions[K] { + type Callback = (...args: Parameters) => void; + const forwarded: Callback = (...args) => { + const callback = options.current[key] as Callback | undefined; + callback?.(...args); + }; + return forwarded as EditorOptions[K]; +} + +function createForwardingEditor(options: RefObject>): Editor { + const callbacks: Partial = {}; + for (const key of EDITOR_CALLBACK_KEYS) { + Object.assign(callbacks, { [key]: forwardCallback(options, key) }); + } + return new Editor({ ...options.current, ...callbacks }); +} + +// Matches @tiptap/react's useEditor: callbacks are forwarded, so only the +// remaining options decide whether the editor needs `setOptions`. +function editorOptionsMatch(next: Partial, current: Partial) { + return (Object.keys(next) as Array).every((key) => { + if ((EDITOR_CALLBACK_KEYS as ReadonlyArray).includes(key)) return true; + if (key === "extensions" && next.extensions && current.extensions) { + const currentExtensions = current.extensions; + return ( + next.extensions.length === currentExtensions.length && + next.extensions.every((extension, index) => extension === currentExtensions[index]) + ); + } + return next[key] === current[key]; + }); +} + +interface KeptEditorInstance { + editor: Editor; + deps: DependencyList; + mounted: boolean; + cancelDestroy: (() => void) | null; +} + +function destroyUnlessMounted(instance: KeptEditorInstance) { + if (!instance.mounted && !instance.editor.isDestroyed) instance.editor.destroy(); +} + +function destroyOnNextTask(instance: KeptEditorInstance) { + const timeout = setTimeout(() => destroyUnlessMounted(instance), 1); + return () => clearTimeout(timeout); +} + +/** + * `useEditor` from @tiptap/react, except that hiding a kept thread view does + * not destroy the editor: its content, selection and undo history are there + * when the view is shown again. Outside a kept view, an unmounted editor is + * destroyed on the next task, as with `useEditor`. Changed `deps` recreate it. + */ +export function useKeptEditor(options: Partial, deps: DependencyList): Editor { + const optionsRef = useRef(options); + optionsRef.current = options; + const lifetime = useContext(KeptViewLifetimeContext); + const [, rerender] = useReducer((count: number) => count + 1, 0); + const [instance] = useState(() => { + const created: KeptEditorInstance = { + editor: createForwardingEditor(optionsRef), + deps, + mounted: false, + cancelDestroy: null, + }; + // A render React discards never mounts; free its editor. + created.cancelDestroy = destroyOnNextTask(created); + return created; + }); + + useEffect(() => { + if (instance.editor.isDestroyed) return; + if ( + deps.length !== instance.deps.length || + deps.some((dep, index) => !Object.is(dep, instance.deps[index])) + ) { + instance.deps = deps; + instance.editor.destroy(); + instance.editor = createForwardingEditor(optionsRef); + rerender(); + return; + } + if (!editorOptionsMatch(optionsRef.current, instance.editor.options)) { + instance.editor.setOptions({ ...optionsRef.current, editable: instance.editor.isEditable }); + } + }); + + useEffect(() => { + instance.mounted = true; + instance.cancelDestroy?.(); + instance.cancelDestroy = null; + if (instance.editor.isDestroyed) { + instance.editor = createForwardingEditor(optionsRef); + rerender(); + } + return () => { + instance.mounted = false; + // Still in the document means the kept view was hidden, not removed. + instance.cancelDestroy = + lifetime !== null && instance.editor.view.dom?.isConnected === true + ? lifetime.hold(() => destroyUnlessMounted(instance)) + : destroyOnNextTask(instance); + }; + }, [instance, lifetime]); + + return instance.editor; +} + +class KeptPureEditorContent extends PureEditorContent { + static override contextType = KeptViewLifetimeContext; + declare context: KeptViewLifetime | null; + + override componentWillUnmount() { + // Inside a kept view the editor's DOM and node views stay where they + // are. A hidden view shows them again as is; a removed one destroys + // the editor with it (see useKeptEditor). + if (this.context !== null) return; + super.componentWillUnmount(); + } +} + +/** `EditorContent` from @tiptap/react for editors made by `useKeptEditor`. */ +export const KeptEditorContent = memo( + forwardRef>( + function KeptEditorContent(props, ref) { + // Like EditorContent, a new editor instance gets a fresh content component. + const key = useMemo(() => Math.floor(Math.random() * 0xffffffff).toString(), [props.editor]); + return ; + }, + ), +); diff --git a/apps/web/src/hooks/useElementState.ts b/apps/web/src/hooks/useElementState.ts new file mode 100644 index 0000000000000..d211b2eaf19e3 --- /dev/null +++ b/apps/web/src/hooks/useElementState.ts @@ -0,0 +1,31 @@ +import { useCallback, useRef, useState } from "react"; + +/** + * Element state for a callback ref. A hidden `` (a kept thread + * view) detaches refs but leaves the element in the document, so a null only + * lands once the element has really left it. Hiding and revealing a view + * then keeps its measurements instead of resetting and recomputing them. + */ +export function useElementState(): readonly [ + T | null, + (element: T | null) => void, +] { + const [element, setElement] = useState(null); + const latestRef = useRef(null); + const setRef = useCallback((next: T | null) => { + if (next !== null) { + latestRef.current = next; + setElement(next); + return; + } + const previous = latestRef.current; + if (previous === null) return; + // A removed element is out of the document once the commit finishes. + queueMicrotask(() => { + if (latestRef.current !== previous || previous.isConnected) return; + latestRef.current = null; + setElement(null); + }); + }, []); + return [element, setRef] as const; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index a3ff8aa2bbe49..25fb0abc1e26a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,6 +17,7 @@ import { lazy, Suspense, useEffect, useEffectEvent, useMemo, useRef, useState } import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; +import { ChatSurfaceSlot } from "../components/chatSurfaceSlot"; import { CommandPaletteHost } from "../components/CommandPaletteHost"; import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; @@ -238,6 +239,7 @@ function RootRouteView() { const appShell = ( + diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 0a1bd024f3d69..7efd589f3398f 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -// The view lives in the `_chat` layout (see ThreadRouteView) so a draft's +// The view lives in the root layout (see KeptChatSurface) so a draft's // promotion onto this route keeps the same ChatView mounted. export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ component: () => null, diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 5a7f58cfbc66b..fdb956cac530a 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -// The view lives in the `_chat` layout (see ThreadRouteView) so a draft's +// The view lives in the root layout (see KeptChatSurface) so a draft's // promotion to `/$environmentId/$threadId` keeps the same ChatView mounted. export const Route = createFileRoute("/_chat/draft/$draftId")({ component: () => null, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 29d62bea19cf1..5a7db958e312b 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,11 +1,11 @@ import { AuthPreviewOperateScope } from "@t3tools/contracts"; -import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router"; +import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useMemo } from "react"; +import { useEffect, useLayoutEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; -import { ThreadRouteView } from "../components/ThreadRouteView"; -import { resolveThreadRouteTarget } from "../threadRoutes"; +import { registerChatSurface } from "../components/chatSurfaceSlot"; +import { KeptChatSurface } from "../components/ThreadRouteView"; import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; @@ -215,16 +215,13 @@ function ChatRouteGlobalShortcuts() { } function ChatRouteLayout() { - // Both thread routes render here, not in their own leaf components, so the - // draft-to-thread promotion keeps one ChatView mounted across the swap. - const threadTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); + // The thread routes render nothing here: the root layout hosts their view + // (see KeptChatSurface) so it outlives the route swaps around it. + useLayoutEffect(() => registerChatSurface(KeptChatSurface), []); return ( <> - {threadTarget ? : } + ); } diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 8d6c036669c60..60b453829facc 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -1,4 +1,8 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + parseScopedThreadKey, + scopeThreadRef, + scopedThreadKey, +} from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import type { DraftId } from "./composerDraftStore"; @@ -84,6 +88,25 @@ export function resolveThreadRouteTarget( }; } +/** A string identity for a route target, stable across renders. */ +export function threadRouteTargetKey(target: ThreadRouteTarget | null): string | null { + if (target === null) return null; + return target.kind === "server" + ? `server:${scopedThreadKey(target.threadRef)}` + : `draft:${target.draftId}`; +} + +export function threadRouteTargetFromKey(key: string | null): ThreadRouteTarget | null { + if (key === null) return null; + if (key.startsWith("draft:")) { + return { kind: "draft", draftId: key.slice("draft:".length) as DraftId }; + } + const threadRef = key.startsWith("server:") + ? parseScopedThreadKey(key.slice("server:".length)) + : null; + return threadRef === null ? null : { kind: "server", threadRef }; +} + /** * Resolves the thread represented by either a canonical thread route or a * draft route whose promotion to a server thread has been recorded. @@ -100,3 +123,77 @@ export function resolveActiveThreadRouteRef( } return draftThread.promotedTo; } + +/** A chat view the thread route keeps mounted, shown or hidden by `ThreadRouteView`. */ +export interface KeptThreadView { + /** React key. A promoted draft keeps its draft id so its view survives the route swap. */ + readonly key: string; + readonly threadKey: string; + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + /** Set while the view is a draft route's; drafts are not kept once left. */ + readonly draftId: DraftId | null; + /** Visit order, used to evict the least recently shown view. */ + readonly visit: number; +} + +/** Recently shown server threads stay rendered, so a revisit only reveals them. */ +export const KEPT_THREAD_VIEW_LIMIT = 4; + +/** + * Adds or refreshes the active view and evicts the least recently shown + * ones past `limit`. Entries never reorder: moving a rendered view in the + * DOM would drop its scroll position. Returns `current` when nothing changed. + */ +export function resolveKeptThreadViews( + current: ReadonlyArray, + active: Omit | null, + limit = KEPT_THREAD_VIEW_LIMIT, +): ReadonlyArray { + const lastVisit = current.reduce((max, view) => Math.max(max, view.visit), 0); + // A server thread reuses whichever view already shows it, which is how a + // promoted draft's view carries over to the thread route. + const activeIndex = + active === null + ? -1 + : current.findIndex((view) => + active.draftId === null ? view.threadKey === active.threadKey : view.key === active.key, + ); + let next = current.filter((view, index) => view.draftId === null || index === activeIndex); + if (active !== null) { + const existing = activeIndex === -1 ? undefined : current[activeIndex]; + if (existing === undefined) { + next = [...next, { ...active, visit: lastVisit + 1 }]; + } else if ( + existing.draftId !== active.draftId || + existing.environmentId !== active.environmentId || + existing.threadId !== active.threadId || + existing.visit !== lastVisit + ) { + const refreshed: KeptThreadView = { + ...existing, + environmentId: active.environmentId, + threadId: active.threadId, + threadKey: active.threadKey, + draftId: active.draftId, + visit: existing.visit === lastVisit ? lastVisit : lastVisit + 1, + }; + next = next.map((view) => (view === existing ? refreshed : view)); + } + } + while (next.length > limit) { + const evicted = next.reduce( + (oldest, view) => + (active !== null && view.threadKey === active.threadKey) || + (oldest !== null && oldest.visit <= view.visit) + ? oldest + : view, + null, + ); + if (evicted === null) break; + next = next.filter((view) => view !== evicted); + } + return next.length === current.length && next.every((view, index) => view === current[index]) + ? current + : next; +} From 1d984328f4b67f66ac065c920c34b083bc38400e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 01:08:33 +0000 Subject: [PATCH 019/241] perf(web): render only visible pull request rows The Pull Requests page rendered every listed row on each visit, about 6,500 DOM nodes for 111 pull requests and a 400-600 ms frame on revisit. The rows now go through LegendList with the filters as its header and the states and paging controls as its footer, so a visit renders the rows in view. Kept thread views also release their editors only when they unmount, not when the chat is hidden behind a full page. --- apps/web/src/components/ThreadRouteView.tsx | 15 +- apps/web/src/components/chatSurfaceSlot.tsx | 13 +- apps/web/src/routes/_chat.pull-requests.tsx | 284 ++++++++++++++------ apps/web/src/routes/_chat.tsx | 2 +- 4 files changed, 223 insertions(+), 91 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 49f98170488ed..d81352e091c09 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -198,7 +198,7 @@ export const ThreadRouteView = memo(function ThreadRouteView({ } // One lifetime per kept view, released once the view is gone for good. const lifetimesRef = useRef(new Map>()); - const pendingUnmountDisposeRef = useRef | null>(null); + const insetRef = useRef(null); const lifetimeFor = (key: string) => { let lifetime = lifetimesRef.current.get(key); if (lifetime === undefined) { @@ -217,10 +217,12 @@ export const ThreadRouteView = memo(function ThreadRouteView({ }, [keptViews]); useEffect(() => { const lifetimes = lifetimesRef.current; - if (pendingUnmountDisposeRef.current !== null) clearTimeout(pendingUnmountDisposeRef.current); + const inset = insetRef.current; return () => { - // A task later, so StrictMode's simulated remount can cancel it. - pendingUnmountDisposeRef.current = setTimeout(() => { + // Hidden behind a full page, the views stay in the document and keep their + // lifetimes; only an unmount releases them. + setTimeout(() => { + if (inset?.isConnected) return; for (const lifetime of lifetimes.values()) lifetime.dispose(); lifetimes.clear(); }, 0); @@ -236,7 +238,10 @@ export const ThreadRouteView = memo(function ThreadRouteView({ )?.key ?? null); return ( - + {nextKeptViews.map((view) => ( diff --git a/apps/web/src/components/chatSurfaceSlot.tsx b/apps/web/src/components/chatSurfaceSlot.tsx index 214252e9aaa27..95d96203e2f70 100644 --- a/apps/web/src/components/chatSurfaceSlot.tsx +++ b/apps/web/src/components/chatSurfaceSlot.tsx @@ -1,6 +1,6 @@ -import { useSyncExternalStore, type ComponentType } from "react"; +import { useSyncExternalStore, type ReactNode } from "react"; -let surface: ComponentType | null = null; +let surface: ReactNode = null; const listeners = new Set<() => void>(); function subscribe(listener: () => void) { @@ -16,13 +16,12 @@ function subscribe(listener: () => void) { * chat for a full page hides the thread view instead of unmounting it, while * routes that never open a thread do not load the chat code. */ -export function registerChatSurface(component: ComponentType) { - if (surface === component) return; - surface = component; +export function registerChatSurface(element: ReactNode) { + if (surface !== null) return; + surface = element; for (const listener of listeners) listener(); } export function ChatSurfaceSlot() { - const Surface = useSyncExternalStore(subscribe, () => surface); - return Surface ? : null; + return useSyncExternalStore(subscribe, () => surface); } diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index c6b0591ee2578..b89de54147a0e 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1,3 +1,4 @@ +import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { Spinner } from "~/components/ui/spinner"; import { useShortcutModifierState } from "~/shortcutModifierState"; @@ -229,6 +230,70 @@ function PullRequestGroupHeader({ ); } +type PullRequestListItem = + | { + readonly kind: "group"; + readonly key: string; + readonly first: boolean; + readonly group: { key: string; label: string; entries: ReadonlyArray }; + } + | { + readonly kind: "row"; + readonly key: string; + /** Rows sit 2px apart within a group and groups 12px apart, as the stacked list did. */ + readonly gap: "none" | "row" | "group"; + readonly entryKey: string; + readonly entry: EnvironmentPullRequestEntry; + }; + +const EMPTY_PULL_REQUEST_LIST_ITEMS: ReadonlyArray = []; + +/** The grouped rows as one flat list, so the page renders only the rows in view. */ +function pullRequestListItems( + groups: ReadonlyArray<{ + readonly key: string; + readonly label: string; + readonly entries: ReadonlyArray; + }>, +): ReadonlyArray { + const items: PullRequestListItem[] = []; + for (const [groupIndex, group] of groups.entries()) { + if (group.label) { + items.push({ kind: "group", key: `group:${group.key}`, first: groupIndex === 0, group }); + } + for (const [index, entry] of group.entries.entries()) { + const entryKey = pullRequestEntryKey(entry); + const startsGroup = index === 0 && !group.label; + items.push({ + kind: "row", + key: `${group.key}:${entryKey}`, + gap: !startsGroup ? "row" : groupIndex === 0 ? "none" : "group", + entryKey, + entry, + }); + } + } + return items; +} + +const pullRequestListItemKey = (item: PullRequestListItem) => item.key; +const pullRequestListItemType = (item: PullRequestListItem) => item.kind; + +/** One list item at the page's content width. */ +function PullRequestListLane({ + className, + children, +}: { + className?: string | undefined; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + // The state filters wear the same glyphs the rows do, so the two read as one vocabulary. const INVOLVEMENT_TABS = [ { value: "all", label: "All", Icon: LayersIcon }, @@ -1871,6 +1936,77 @@ function PullRequestsRouteView() { // in its own words and is left to. const carriedToNothing = showingCarried && listQuery.isPending && shownCount === 0 && typedQuery.length === 0; + const listRowsShown = + capabilityKnown && + pullRequestsSupported && + !firstLoad && + !(listQuery.error && shownCount === 0) && + !carriedToNothing && + shownCount > 0; + const listItems = useMemo( + () => (listRowsShown ? pullRequestListItems(displayGroups) : EMPTY_PULL_REQUEST_LIST_ITEMS), + [displayGroups, listRowsShown], + ); + const renderListItem = ({ item }: { item: PullRequestListItem }) => { + if (item.kind === "group") { + return ( + + + + ); + } + const { entry, entryKey } = item; + return ( + + 1 && + environmentLabels.get(entry.environmentId) !== undefined + ? { environmentLabel: environmentLabels.get(entry.environmentId)! } + : {})} + // Ten is the floor the ranking gives a row whose own fields say nothing + // about the search: the host matched something this row cannot show. + matchedElsewhere={ + typedParsed.text.length > 0 && + scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE + } + selected={ + selected?.environmentId === entry.environmentId && + selected.repository === entry.repository && + selected.host?.toLowerCase() === entry.host.toLowerCase() && + selected.number === entry.number + } + onSelect={selectEntry} + speedMode={speedMode} + onActed={onSpeedAction} + closing={closingKeys.has(entryKey)} + sweeping={closeSweepKeys.has(entryKey)} + onCloseSweepStart={startCloseSweep} + /> + + ); + }; + // Everything a visible row reads; a change re-renders the rows the list has mounted. + const listExtraData = [ + capableEnvironments.length, + environmentLabels, + typedParsed.text, + selected, + selectEntry, + speedMode, + onSpeedAction, + closingKeys, + closeSweepKeys, + showProvider, + registerStatsRow, + startCloseSweep, + ]; const listBody = ( <> {!capabilityKnown ? ( @@ -1909,50 +2045,7 @@ function PullRequestsRouteView() { onClearQuery={() => updateListScope({ q: undefined })} onLoadMore={loadMore} /> - ) : ( -
0 && "**:pointer-events-none")}> - {displayGroups.map((group) => ( -
- {group.label ? : null} - {group.entries.map((entry) => { - const entryKey = pullRequestEntryKey(entry); - return ( - 1 && - environmentLabels.get(entry.environmentId) !== undefined - ? { environmentLabel: environmentLabels.get(entry.environmentId)! } - : {})} - // Ten is the floor the ranking gives a row whose own fields say nothing - // about the search: the host matched something this row cannot show. - matchedElsewhere={ - typedParsed.text.length > 0 && - scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE - } - selected={ - selected?.environmentId === entry.environmentId && - selected.repository === entry.repository && - selected.host?.toLowerCase() === entry.host.toLowerCase() && - selected.number === entry.number - } - onSelect={selectEntry} - speedMode={speedMode} - onActed={onSpeedAction} - closing={closingKeys.has(entryKey)} - sweeping={closeSweepKeys.has(entryKey)} - onCloseSweepStart={startCloseSweep} - /> - ); - })} -
- ))} -
- )} + ) : null} {listQuery.error && shownCount > 0 ? (
@@ -2119,6 +2212,10 @@ function PullRequestsRouteView() { ) : null, rightPanelOpen: rightPanelState.isOpen, listBody, + listItems, + renderListItem, + listExtraData, + listLocked: closeSweepKeys.size > 0, scrollRef, }; @@ -2515,6 +2612,10 @@ function PullRequestsColumn({ titlebarControls, rightPanelOpen, listBody, + listItems, + renderListItem, + listExtraData, + listLocked, scrollRef, }: { refreshing: boolean; @@ -2534,9 +2635,19 @@ function PullRequestsColumn({ titlebarControls: ReactNode; rightPanelOpen: boolean; listBody: ReactNode; + listItems: ReadonlyArray; + renderListItem: (info: { item: PullRequestListItem }) => ReactNode; + listExtraData: unknown; + listLocked: boolean; scrollRef: RefObject; }) { const markerRef = useRef(null); + const attachList = useCallback( + (list: LegendListRef | null) => { + scrollRef.current = (list?.getScrollableNode() as HTMLDivElement | null) ?? null; + }, + [scrollRef], + ); const [condensed, setCondensed] = useState(false); useEffect(() => { const marker = markerRef.current; @@ -2666,42 +2777,59 @@ function PullRequestsColumn({ {rightPanelControl} -
- {/* The top padding is the shared fade band's height, the same pairing the - settings page makes: at rest the controls sit fully below the mask, and only - content actually passing under the chrome fades. */} - -
-
-
- {searchInput} + {/* Only the rows in view render; the controls scroll with them as the list header. */} + + ref={attachList} + data={listItems} + keyExtractor={pullRequestListItemKey} + getItemType={pullRequestListItemType} + renderItem={renderListItem} + extraData={listExtraData} + estimatedItemSize={56} + recycleItems={false} + className={cn( + "topbar-scroll-fade scrollbar-gutter-both min-h-0 flex-1 overflow-x-hidden", + listLocked && "**:pointer-events-none", + )} + // The footer fills what the rows leave, so the empty states stay centered. + contentContainerClassName="flex flex-col [&>:last-child]:flex [&>:last-child]:flex-1 [&>:last-child]:flex-col" + ListHeaderComponent={ + +
+
+
+ {searchInput} +
+ {sortMenu} + {filtersMenu} + } + triggerLabel="All" + value={host ?? ""} + options={hostMenuOptions} + onChange={(next) => onHost(next === "" ? undefined : next)} + /> + {!condensed ? ( + + ) : null}
- {sortMenu} - {filtersMenu} - } - triggerLabel="All" - value={host ?? ""} - options={hostMenuOptions} - onChange={(next) => onHost(next === "" ? undefined : next)} - /> - {!condensed ? ( - - ) : null} + {/* Scrolled past this marker, the controls are gone and the title takes over. */} +
- {/* Scrolled past this marker, the controls are gone and the title takes over. */} -
-
- - {listBody} - -
+
+ } + ListFooterComponent={ + 0 ? "pt-4" : "pt-0")} + > + {listBody} + + } + />
); } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 5a7db958e312b..20377c3734f75 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -217,7 +217,7 @@ function ChatRouteGlobalShortcuts() { function ChatRouteLayout() { // The thread routes render nothing here: the root layout hosts their view // (see KeptChatSurface) so it outlives the route swaps around it. - useLayoutEffect(() => registerChatSurface(KeptChatSurface), []); + useLayoutEffect(() => registerChatSurface(), []); return ( <> From 981ae9839cdc0b1db6f3a63d7a30fb7e48b20d5f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 13:15:46 +0000 Subject: [PATCH 020/241] perf(web): open long settings lists and server toggles at once The open source licenses page rendered all 543 notices before its first paint, and keybindings rendered every row with its tooltip, popover and menu. Both now paint the first rows with the page and the rest in a transition right after, and offscreen license rows skip style and layout. Server-scoped switches waited for the server round trip before flipping. A write now shows at once on top of the environment's settings, rolls back when it fails (the existing toast reports it), and gives way to the server's settings once they are pushed. --- .../settings/KeybindingsSettings.tsx | 38 ++++++---- .../settings/OpenSourceLicenses.tsx | 12 ++- .../settings/SettingsScopeContext.tsx | 30 +++++++- .../settings/optimisticServerSettings.ts | 75 +++++++++++++++++++ .../components/settings/useScopedSettings.ts | 19 ++++- apps/web/src/hooks/useProgressiveRowLimit.ts | 18 +++++ apps/web/src/index.css | 9 --- 7 files changed, 174 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/settings/optimisticServerSettings.ts create mode 100644 apps/web/src/hooks/useProgressiveRowLimit.ts diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 13812d8198dc2..32d3e80c49bd6 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -76,6 +76,7 @@ import { SettingsGroup } from "./SettingsGroup"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { useProgressiveRowLimit } from "../../hooks/useProgressiveRowLimit"; import { useAtomCommand } from "../../state/use-atom-command"; function KeybindingPill({ value }: { value: string }) { @@ -1243,21 +1244,32 @@ interface KeybindingsGroupsProps extends KeybindingRowActions { /** One titled section per command area, each holding its binding rows. */ function KeybindingsGroups(props: KeybindingsGroupsProps) { const { groups, anchorIds, savingCommand, ...rowActions } = props; - return groups.map((group) => ( - - {group.rows.map((row) => ( - - ))} - - )); + const rowLimit = useProgressiveRowLimit(INITIAL_KEYBINDING_ROWS); + let rowsBefore = 0; + return groups.map((group) => { + const budget = rowLimit - rowsBefore; + rowsBefore += group.rows.length; + if (budget <= 0) return null; + const rows = group.rows.length > budget ? group.rows.slice(0, budget) : group.rows; + return ( + + {rows.map((row) => ( + + ))} + + ); + }); } +/** Enough rows to fill a tall window; the rest render right after the first paint. */ +const INITIAL_KEYBINDING_ROWS = 24; + /** Shown in the browser build only; the desktop app receives every shortcut. */ function BrowserKeybindingNotice() { // The label carries the whole sentence so assistive tech reads it without diff --git a/apps/web/src/components/settings/OpenSourceLicenses.tsx b/apps/web/src/components/settings/OpenSourceLicenses.tsx index 02a87caf7ac78..c3cdea4accd56 100644 --- a/apps/web/src/components/settings/OpenSourceLicenses.tsx +++ b/apps/web/src/components/settings/OpenSourceLicenses.tsx @@ -13,9 +13,13 @@ import { import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { useProgressiveRowLimit } from "../../hooks/useProgressiveRowLimit"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +/** Enough rows to fill a tall window; the rest render right after the first paint. */ +const INITIAL_LICENSE_ROWS = 40; + type LicenseManifestState = | { readonly status: "loading" } | { readonly status: "error"; readonly message: string } @@ -43,7 +47,8 @@ function LicenseNoticeRow({ }) { return ( -
+ {/* Rows out of view skip style and layout until scrolled to. */} +
setRequestVersion((value) => value + 1), []); + const rowLimit = useProgressiveRowLimit(INITIAL_LICENSE_ROWS); + const shownEntries = + filteredEntries.length > rowLimit ? filteredEntries.slice(0, rowLimit) : filteredEntries; return ( @@ -240,7 +248,7 @@ export function OpenSourceLicensesPanel() { {state.status === "ready" ? (
{filteredEntries.length > 0 ? ( - filteredEntries.map((entry) => { + shownEntries.map((entry) => { const entryKey = thirdPartyLicenseEntryKey(entry); return ( ) { function useResolvedSettingsScope(rawSearch: SettingsScopeSearch, singleEnvironment: boolean) { const groups = useSettingsProjectGroups(); - const { environments: availableEnvironments } = useEnvironments(); + const { environments: liveEnvironments } = useEnvironments(); + // Writes in flight show at once; a failed one falls back to the server's value. + const optimisticPatches = useOptimisticServerSettingsPatches(); + const availableEnvironments = useMemo( + () => + optimisticPatches.length === 0 + ? liveEnvironments + : liveEnvironments.map((environment) => + environment.serverConfig === null || + !optimisticPatches.some((entry) => entry.environmentId === environment.environmentId) + ? environment + : { + ...environment, + serverConfig: { + ...environment.serverConfig, + settings: applyOptimisticServerSettings( + environment.environmentId, + environment.serverConfig.settings, + optimisticPatches, + ), + }, + }, + ), + [liveEnvironments, optimisticPatches], + ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const search = useMemo( () => diff --git a/apps/web/src/components/settings/optimisticServerSettings.ts b/apps/web/src/components/settings/optimisticServerSettings.ts new file mode 100644 index 0000000000000..596d70232e830 --- /dev/null +++ b/apps/web/src/components/settings/optimisticServerSettings.ts @@ -0,0 +1,75 @@ +import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { useSyncExternalStore } from "react"; + +/** + * Server setting writes shown before the server confirms them. A failed write + * drops its patch, so the control returns to the server's value while the + * caller reports the error. + */ +export interface OptimisticServerSettingsPatch { + readonly environmentId: EnvironmentId; + readonly patch: ServerSettingsPatch; + /** The environment's settings object when the write was sent. */ + readonly base: ServerSettings | null; + /** Saved: the patch only bridges the gap until the server pushes settings newer than `base`. */ + saved: boolean; +} + +/** Bounds a saved patch whose settings push never arrives (a reconnect, say). */ +const SAVED_PATCH_MAX_MS = 5_000; + +let patches: ReadonlyArray = []; +const listeners = new Set<() => void>(); + +function publish(next: ReadonlyArray) { + patches = next; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Shows `patch` at once. Call the result with whether the write succeeded. */ +export function beginOptimisticServerSettingsPatch( + environmentId: EnvironmentId, + patch: ServerSettingsPatch, + base: ServerSettings | null, +): (saved: boolean) => void { + const pending: OptimisticServerSettingsPatch = { environmentId, patch, base, saved: false }; + publish([...patches, pending]); + return (saved) => { + if (!saved) { + publish(patches.filter((entry) => entry !== pending)); + return; + } + // The settings push normally lands before the reply and has already re-rendered the + // page; marking the patch saved needs no render of its own. + pending.saved = true; + setTimeout(() => publish(patches.filter((entry) => entry !== pending)), SAVED_PATCH_MAX_MS); + }; +} + +export function useOptimisticServerSettingsPatches(): ReadonlyArray { + return useSyncExternalStore(subscribe, () => patches); +} + +/** `settings` as the environment will have them once its writes land. */ +export function applyOptimisticServerSettings( + environmentId: EnvironmentId, + settings: ServerSettings, + pending: ReadonlyArray, +): ServerSettings { + let next = settings; + for (const entry of pending) { + if (entry.environmentId !== environmentId) continue; + // The server's own settings have arrived since the write was sent. + if (entry.saved && settings !== entry.base) continue; + next = applyServerSettingsPatch(next, entry.patch); + } + return next; +} diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index 4bed07267b133..a48a281c9c7af 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -20,7 +20,9 @@ import { persistClientSettingsPatch, useClientSettings, } from "../../hooks/useSettings"; -import { serverEnvironment } from "../../state/server"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server"; +import { beginOptimisticServerSettingsPatch } from "./optimisticServerSettings"; import { useAtomCommand } from "../../state/use-atom-command"; import { toastManager } from "../ui/toast"; import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext"; @@ -87,7 +89,20 @@ function useRunScopedPlan() { }), ), ); - return persistServer(request); + const settle = beginOptimisticServerSettingsPatch( + request.environmentId, + request.input.patch, + appAtomRegistry.get(environmentServerConfigsAtom).get(request.environmentId) + ?.settings ?? null, + ); + let saved = false; + try { + const result = await persistServer(request); + saved = result._tag === "Success"; + return result; + } finally { + settle(saved); + } }, persistClientSettingsPatch, ).then(({ failedEnvironments, savedEnvironments, savedEnvironmentCount }) => { diff --git a/apps/web/src/hooks/useProgressiveRowLimit.ts b/apps/web/src/hooks/useProgressiveRowLimit.ts new file mode 100644 index 0000000000000..9ea5569b88e7b --- /dev/null +++ b/apps/web/src/hooks/useProgressiveRowLimit.ts @@ -0,0 +1,18 @@ +import { startTransition, useEffect, useState } from "react"; + +/** + * How many rows of a long list to render. The first paint gets `initial` + * rows and the rest follow in a transition, so opening the page paints at + * once and rendering the remainder never blocks input. + */ +export function useProgressiveRowLimit(initial: number): number { + const [limit, setLimit] = useState(initial); + useEffect(() => { + if (limit === Number.POSITIVE_INFINITY) return; + const frame = requestAnimationFrame(() => + startTransition(() => setLimit(Number.POSITIVE_INFINITY)), + ); + return () => cancelAnimationFrame(frame); + }, [limit]); + return limit; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b59b8323f8a33..66a02f2836434 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1075,15 +1075,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil background-size: var(--surface-grain-size); } -/* Suppress all transitions during theme changes */ -.no-transitions, -.no-transitions *, -.no-transitions *::before, -.no-transitions *::after { - transition-duration: 0s !important; - animation-duration: 0s !important; -} - :root { color-scheme: light; --radius: 0.625rem; From 04a9785be7050b037ff22fddc2c6243e2d53d266 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 01:58:49 +0000 Subject: [PATCH 021/241] perf(web): trim per-keystroke shortcut checks Every keydown ran isMacPlatform's regex several times and matched each type-to-focus selector with closest() from every entry of the composed path. The platform answer is cached and each path entry is matched once, which covers the same ancestors. Also covers the kept thread view pool and the optimistic settings overlay in the existing route and settings tests. --- apps/web/src/components/ChatView.tsx | 6 +- .../settings/scopedSettings.test.ts | 36 +++++++++++ apps/web/src/threadRoutes.test.ts | 63 +++++++++++++++++++ packages/shared/src/keybindings.ts | 10 ++- 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 35b173199ea01..c4d422af2a4a6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -810,9 +810,11 @@ type EnvironmentUnavailableState = { function eventPathContainsSelector(event: Event, selector: string): boolean { const path = event.composedPath(); if (path.length === 0 && event.target) { - path.push(event.target); + return event.target instanceof Element && event.target.closest(selector) !== null; } - return path.some((target) => target instanceof Element && target.closest(selector)); + // The composed path already lists every ancestor, so one match per entry + // covers what `closest` from each entry would. + return path.some((target) => target instanceof Element && target.matches(selector)); } const SECRET_REQUEST_SELECTOR = '[data-v2-item-type="secret_request"]'; diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index a47a4bf002415..41706e3d1321b 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -23,6 +23,10 @@ import { selectScopedSettingsEnvironments, } from "./scopedSettings"; import { resolveSettingsScope } from "./settingsScope"; +import { + applyOptimisticServerSettings, + type OptimisticServerSettingsPatch, +} from "./optimisticServerSettings"; function environment( id: string, @@ -644,3 +648,35 @@ describe("partial object patches at project scope", () => { }); }); }); + +describe("applyOptimisticServerSettings", () => { + const environmentId = EnvironmentId.make("env-optimistic"); + const flipped = !DEFAULT_SERVER_SETTINGS.sidebarAutoSettleOnMerge; + const pending = (saved: boolean): OptimisticServerSettingsPatch => ({ + environmentId, + patch: { sidebarAutoSettleOnMerge: flipped }, + base: DEFAULT_SERVER_SETTINGS, + saved, + }); + + it("shows a write in flight on its own environment only", () => { + expect( + applyOptimisticServerSettings(environmentId, DEFAULT_SERVER_SETTINGS, [pending(false)]) + .sidebarAutoSettleOnMerge, + ).toBe(flipped); + expect( + applyOptimisticServerSettings(EnvironmentId.make("env-other"), DEFAULT_SERVER_SETTINGS, [ + pending(false), + ]), + ).toBe(DEFAULT_SERVER_SETTINGS); + }); + + it("keeps a saved write until the server's settings replace the ones it was sent against", () => { + expect( + applyOptimisticServerSettings(environmentId, DEFAULT_SERVER_SETTINGS, [pending(true)]) + .sidebarAutoSettleOnMerge, + ).toBe(flipped); + const pushed: ServerSettings = { ...DEFAULT_SERVER_SETTINGS }; + expect(applyOptimisticServerSettings(environmentId, pushed, [pending(true)])).toBe(pushed); + }); +}); diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index 34380d1266891..a812872b0b781 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -7,9 +7,13 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams, resolveActiveThreadRouteRef, + resolveKeptThreadViews, resolveThreadRouteRenderState, resolveThreadRouteRef, resolveThreadRouteTarget, + threadRouteTargetFromKey, + threadRouteTargetKey, + type KeptThreadView, } from "./threadRoutes"; describe("threadRoutes", () => { @@ -153,4 +157,63 @@ describe("threadRoutes", () => { }), ).toBe("missing"); }); + + it("round-trips a route target through its key", () => { + const server = resolveThreadRouteTarget({ environmentId: "env-1", threadId: "thread-1" }); + const draft = resolveThreadRouteTarget({ draftId: "draft-1" }); + expect(threadRouteTargetFromKey(threadRouteTargetKey(server))).toEqual(server); + expect(threadRouteTargetFromKey(threadRouteTargetKey(draft))).toEqual(draft); + expect(threadRouteTargetKey(null)).toBeNull(); + expect(threadRouteTargetFromKey(null)).toBeNull(); + }); +}); + +describe("resolveKeptThreadViews", () => { + const serverView = (id: string): Omit => ({ + key: `env-1:${id}`, + threadKey: `env-1:${id}`, + environmentId: "env-1" as never, + threadId: ThreadId.make(id), + draftId: null, + }); + const keep = ( + views: ReadonlyArray, + active: Omit | null, + limit?: number, + ) => resolveKeptThreadViews(views, active, limit); + + it("adds each visited thread in place and returns the same list when nothing changes", () => { + const first = keep([], serverView("a")); + const second = keep(first, serverView("b")); + expect(second.map((view) => view.threadId)).toEqual(["a", "b"]); + expect(keep(second, serverView("b"))).toBe(second); + }); + + it("revisits a kept thread without moving it", () => { + const views = keep(keep(keep([], serverView("a")), serverView("b")), serverView("a")); + expect(views.map((view) => view.threadId)).toEqual(["a", "b"]); + expect(views[0]!.visit).toBeGreaterThan(views[1]!.visit); + }); + + it("evicts the least recently shown thread past the limit", () => { + let views = keep([], serverView("a"), 2); + views = keep(views, serverView("b"), 2); + views = keep(views, serverView("a"), 2); + views = keep(views, serverView("c"), 2); + expect(views.map((view) => view.threadId)).toEqual(["a", "c"]); + }); + + it("keeps a promoted draft's view, and its key, on the thread route", () => { + const draftId = DraftId.make("draft-1"); + const draft = keep([], { ...serverView("t"), key: draftId, draftId }); + const promoted = keep(draft, serverView("t")); + expect(promoted).toHaveLength(1); + expect(promoted[0]).toMatchObject({ key: draftId, draftId: null, threadId: "t" }); + }); + + it("drops a draft's view once another view is shown", () => { + const draftId = DraftId.make("draft-1"); + const views = keep(keep([], serverView("a")), { ...serverView("t"), key: draftId, draftId }); + expect(keep(views, serverView("a")).map((view) => view.key)).toEqual(["env-1:a"]); + }); }); diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 7620b2432a776..ffdac3318707c 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -53,8 +53,16 @@ const EVENT_CODE_SHORTCUT_KEYS: Readonly> = { Slash: "/", }; +let lastPlatform: string | null = null; +let lastPlatformIsMac = false; + +/** Keydown handlers ask this for every key, always with the same platform string. */ export function isMacPlatform(platform: string): boolean { - return /mac|iphone|ipad|ipod/i.test(platform); + if (platform !== lastPlatform) { + lastPlatform = platform; + lastPlatformIsMac = /mac|iphone|ipad|ipod/i.test(platform); + } + return lastPlatformIsMac; } export function normalizeEventKey(key: string): string { From eb49ee51c51b09e8de022904b38c5cddd720906f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 02:14:21 +0000 Subject: [PATCH 022/241] fix(web): pause media and keep editors made in a transition for kept views A hidden thread view keeps its DOM, so a video or audio clip it was playing would go on playing. Media now pauses when its view is hidden or the chat goes behind a full page, as leaving a thread did. Thread views mount in transitions, which can commit after the next task; an editor created during such a render now waits for the commit instead of being destroyed and rebuilt. The pull request list also stops re-rendering its rows when nothing they read changed. --- apps/web/src/components/ThreadRouteView.tsx | 20 ++++++++-- apps/web/src/components/keptView.tsx | 38 +++++++++++++----- apps/web/src/routes/_chat.pull-requests.tsx | 44 ++++++++++++++------- 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index d81352e091c09..6988223a1b23d 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -46,14 +46,15 @@ const KeptChatView = memo(ChatView); * `/$environmentId/$threadId`. Each draft gets its own ChatView instance (so * a background send's state stays with the draft it came from), and that * instance carries the draft through its promotion to a server thread: the - * thread route keeps keying by the draft id while the draft record exists, - * so the route swap only changes props and the timeline never paints an - * empty frame. + * thread route reuses the view that already shows the thread, under the + * draft's key, so the route swap only changes props and the timeline never + * paints an empty frame. * * The last few server threads stay rendered inside hidden `` * boundaries, so a revisit only reveals the view it already painted. Hidden * views keep their DOM and state; React disconnects their effects and store - * subscriptions until they are shown again. + * subscriptions until they are shown again. A draft's view is dropped once + * another view is shown, as before. * * Rendered by KeptChatSurface rather than by the two leaf routes, since an * element only survives a route swap when the same parent renders it. @@ -236,6 +237,17 @@ export const ThreadRouteView = memo(function ThreadRouteView({ ? view.threadKey === activeView.threadKey : view.key === activeView.key, )?.key ?? null); + // A hidden view keeps its DOM, so its media would keep playing. Pause it + // whenever the shown view changes or the whole chat is hidden, as leaving a + // thread used to. + useLayoutEffect(() => { + const inset = insetRef.current; + return () => { + for (const media of inset?.querySelectorAll("video, audio") ?? []) { + if (!media.paused) media.pause(); + } + }; + }, [activeViewKey]); return ( destroyUnlessMounted(instance), 1); +function destroyAfter(instance: KeptEditorInstance, delayMs: number) { + const timeout = setTimeout(() => destroyUnlessMounted(instance), delayMs); return () => clearTimeout(timeout); } +/** + * How long an editor made during a render may wait for that render to + * commit. Thread views mount in transitions, which can take longer than the + * next task; a render React discards frees its editor after this. + */ +const UNCOMMITTED_EDITOR_GRACE_MS = 5_000; + /** * `useEditor` from @tiptap/react, except that hiding a kept thread view does * not destroy the editor: its content, selection and undo history are there @@ -153,8 +159,7 @@ export function useKeptEditor(options: Partial, deps: DependencyL mounted: false, cancelDestroy: null, }; - // A render React discards never mounts; free its editor. - created.cancelDestroy = destroyOnNextTask(created); + created.cancelDestroy = destroyAfter(created, UNCOMMITTED_EDITOR_GRACE_MS); return created; }); @@ -189,7 +194,7 @@ export function useKeptEditor(options: Partial, deps: DependencyL instance.cancelDestroy = lifetime !== null && instance.editor.view.dom?.isConnected === true ? lifetime.hold(() => destroyUnlessMounted(instance)) - : destroyOnNextTask(instance); + : destroyAfter(instance, 1); }; }, [instance, lifetime]); @@ -209,13 +214,28 @@ class KeptPureEditorContent extends PureEditorContent { } } +const editorContentKeys = new WeakMap(); +let nextEditorContentKey = 0; + +// Like EditorContent, a new editor instance gets a fresh content component. +function editorContentKey(editor: Editor | null): string { + if (editor === null) return "none"; + let key = editorContentKeys.get(editor); + if (key === undefined) { + nextEditorContentKey += 1; + key = String(nextEditorContentKey); + editorContentKeys.set(editor, key); + } + return key; +} + /** `EditorContent` from @tiptap/react for editors made by `useKeptEditor`. */ export const KeptEditorContent = memo( forwardRef>( function KeptEditorContent(props, ref) { - // Like EditorContent, a new editor instance gets a fresh content component. - const key = useMemo(() => Math.floor(Math.random() * 0xffffffff).toString(), [props.editor]); - return ; + return ( + + ); }, ), ); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index b89de54147a0e..04c1dc5bb9e41 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1993,20 +1993,36 @@ function PullRequestsRouteView() { ); }; // Everything a visible row reads; a change re-renders the rows the list has mounted. - const listExtraData = [ - capableEnvironments.length, - environmentLabels, - typedParsed.text, - selected, - selectEntry, - speedMode, - onSpeedAction, - closingKeys, - closeSweepKeys, - showProvider, - registerStatsRow, - startCloseSweep, - ]; + const listExtraData = useMemo( + () => [ + capableEnvironments.length, + environmentLabels, + typedParsed.text, + selected, + selectEntry, + speedMode, + onSpeedAction, + closingKeys, + closeSweepKeys, + showProvider, + registerStatsRow, + startCloseSweep, + ], + [ + capableEnvironments.length, + environmentLabels, + typedParsed.text, + selected, + selectEntry, + speedMode, + onSpeedAction, + closingKeys, + closeSweepKeys, + showProvider, + registerStatsRow, + startCloseSweep, + ], + ); const listBody = ( <> {!capabilityKnown ? ( From f3dd7d015ca541afc3bf1e443fe6a5bbdb9e15ec Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 02:42:57 +0000 Subject: [PATCH 023/241] perf(web): start loading a thread with the click that opens it A thread's view mounts in a transition after the click, and its detail subscription used to start only then. The chat surface now subscribes to the clicked thread's detail right away, so the fetch overlaps the mount. --- apps/web/src/components/ThreadRouteView.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 6988223a1b23d..8fa332c5a75e0 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -27,6 +27,8 @@ import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { useEnvironmentThreadRefs, useThreadRefs, useThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; +import { environmentThreadDetails } from "../state/threads"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { buildThreadRouteParams, resolveKeptThreadViews, @@ -292,6 +294,12 @@ export function KeptChatSurface() { // The URL and sidebar selection commit with the click; the thread view // swap follows in a transition, so opening a thread never holds that frame. const deferredTargetKey = useDeferredValue(targetKey); + // Start loading the thread with the click; its view mounts a transition later. + useEffect(() => { + const urgentTarget = threadRouteTargetFromKey(targetKey); + if (urgentTarget?.kind !== "server") return; + return appAtomRegistry.mount(environmentThreadDetails.threadAtom(urgentTarget.threadRef)); + }, [targetKey]); const [shownTargetKey, setShownTargetKey] = useState(deferredTargetKey); if (deferredTargetKey !== null && deferredTargetKey !== shownTargetKey) { setShownTargetKey(deferredTargetKey); From 3843722c511e56288fc39d9feccc94fbf01f2ad9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 04:09:32 +0000 Subject: [PATCH 024/241] fix(web): keep a draft's chat view through its promotion The kept-view pool matched the promoted thread by scopedThreadKey of the draft session, which carries a projectId and so keyed the project. The view now keys by the session's thread, so sending from a draft keeps its view, composer and timeline through the route swap. --- apps/web/src/components/ThreadRouteView.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 8fa332c5a75e0..db554d05205aa 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -179,7 +179,11 @@ export const ThreadRouteView = memo(function ThreadRouteView({ if (draftSession) { activeView = { key: target.draftId, - threadKey: scopedThreadKey(draftSession), + // Only the thread fields: a draft session also carries a projectId. + threadKey: scopedThreadKey({ + environmentId: draftSession.environmentId, + threadId: draftSession.threadId, + }), environmentId: draftSession.environmentId, threadId: draftSession.threadId, draftId: target.draftId, From c8da558f103052f07dbf310ded7098e2bf5ca31c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:48:58 +0000 Subject: [PATCH 025/241] perf(web): open new threads in the shown view and keep the left one in idle time Mounting a new chat view for a first open cost about 140 ms more than switching the shown view to the thread, so first opens of small threads settled 60-125 ms later than before. A first open now switches the shown view, as the single shared view did, and shows the previous thread's timeline until the new one loads. The thread it showed gets a view of its own again once that thread has loaded and the user has left the app alone for a second: it mounts out of sight, inert, lays out, and is then hidden like any kept view, so a revisit still only reveals it. A warming view does not mark its thread visited, and hiding a view ends its visit so showing it again marks the thread visited as reopening it did. New views start from the last measured composer inset, so their list scrolls to the true end on the first frame. --- apps/web/src/components/ChatView.logic.ts | 2 +- apps/web/src/components/ChatView.tsx | 30 +++- apps/web/src/components/ThreadRouteView.tsx | 149 +++++++++++++---- apps/web/src/components/keptView.tsx | 49 ++++++ apps/web/src/threadRoutes.test.ts | 92 +++++++---- apps/web/src/threadRoutes.ts | 168 +++++++++++++++----- 6 files changed, 385 insertions(+), 105 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b40c1fe158ce0..086303a58dc9f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -426,7 +426,7 @@ export function resolveThreadSwitchTimeline(input: return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey }; } - const lastReady = input.lastReady === undefined ? peekHeldThreadTimeline() : input.lastReady; + const lastReady = input.lastReady ?? peekHeldThreadTimeline(); if ( input.loading && lastReady !== null && diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c4d422af2a4a6..b59e3b03878ae 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -156,6 +156,7 @@ import { type SetStateAction, Suspense, useCallback, + useContext, useEffect, useEffectEvent, useLayoutEffect, @@ -164,6 +165,7 @@ import { useState, } from "react"; import { flushSync } from "react-dom"; +import { KeptViewWarmingContext } from "./keptView"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import { isMacPlatform } from "../lib/utils"; @@ -1624,6 +1626,9 @@ function releaseChatTimelineAnchor(); @@ -2036,8 +2041,12 @@ export default function ChatView(props: ChatViewProps) { // Space the timeline keeps clear above its end. Tracks the overlay while the // composer is expanded and holds that height while it rests, so the resting // composer never exposes rows that its expansion will cover. - const [composerTimelineInset, setComposerTimelineInset] = useState(0); - const composerTimelineInsetRef = useRef(0); + // A new view starts from the last measured inset, so its list scrolls to + // the true end on its first frame instead of correcting once measured. + const [composerTimelineInset, setComposerTimelineInset] = useState( + () => lastComposerTimelineInset, + ); + const composerTimelineInsetRef = useRef(lastComposerTimelineInset); const composerRestingRef = useRef(false); // False while a status bar stands in for the composer (a native subagent). const composerMountedRef = useRef(true); @@ -2997,8 +3006,18 @@ export default function ChatView(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); + // A kept view warming up out of sight is not being viewed (see ThreadRouteView). + const keptViewWarming = useContext(KeptViewWarmingContext); + // Hiding a kept view ends the visit, so showing it again counts as reopening + // the thread, as it did when a single view switched threads. + useEffect( + () => () => { + lastDispatchedVisitRef.current = null; + }, + [], + ); useEffect(() => { - if (!serverThread?.id) return; + if (!serverThread?.id || keptViewWarming) return; const threadUpdatedAt = Date.parse(serverThread.updatedAt); if (Number.isNaN(threadUpdatedAt)) return; const effectiveLastVisitedAt = resolveThreadLastVisitedAt( @@ -3051,6 +3070,7 @@ export default function ChatView(props: ChatViewProps) { ); }, [ activeThreadLocalLastVisitedAt, + keptViewWarming, markThreadVisited, routeThreadKey, serverThread?.environmentId, @@ -4028,9 +4048,6 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, nextEntries: timelineEntries, rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), - // Each thread has its own view (see ThreadRouteView), so another thread's - // timeline is never painted while this one loads. - lastReady: null, }); const displayedTimelineKey = displayedTimeline.displayThreadKey ?? routeThreadKey; const paintOnlyDisplayedTimeline = isPaintOnlyThreadTimeline( @@ -7072,6 +7089,7 @@ export default function ChatView(props: ChatViewProps) { }); if (composerTimelineInsetRef.current !== nextInset) { composerTimelineInsetRef.current = nextInset; + lastComposerTimelineInset = nextInset; setComposerTimelineInset(nextInset); } const mainSurface = composerOverlayElement?.querySelector( diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index db554d05205aa..9bbc7b6ae9f3b 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -4,6 +4,8 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { Activity, memo, + startTransition, + useCallback, useDeferredValue, useEffect, useLayoutEffect, @@ -13,7 +15,7 @@ import { } from "react"; import ChatView from "./ChatView"; -import { createKeptViewLifetime, KeptViewLifetimeContext } from "./keptView"; +import { createKeptViewLifetime, KeptViewFrame, KeptViewLifetimeContext } from "./keptView"; import { resolveDraftPromotionNavigationTarget, threadHasStarted } from "./ChatView.logic"; import { waitForDraftHeroTransition } from "./chat/draftHeroTransition"; import { SidebarInset } from "./ui/sidebar"; @@ -24,13 +26,20 @@ import { useComposerDraftStore, } from "../composerDraftStore"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; -import { useEnvironmentThreadRefs, useThreadRefs, useThreadShell } from "../state/entities"; +import { + useEnvironmentThreadRefs, + useThreadRefs, + useThreadShell, + useThreadStatus, +} from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; import { environmentThreadDetails } from "../state/threads"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { buildThreadRouteParams, + finishWarmingThreadView, + keepReleasedThreadView, resolveKeptThreadViews, resolveThreadRouteRenderState, resolveThreadRouteTarget, @@ -43,6 +52,24 @@ import { // Kept views re-render only when their own thread or route kind changes. const KeptChatView = memo(ChatView); +/** Threads with a kept view, as of the last commit; read by KeptChatSurface. */ +let keptThreadKeys: ReadonlySet = new Set(); + +/** How long the user must leave the app alone before a kept view warms up. */ +const WARM_UP_QUIET_MS = 1_000; +let lastUserInputAt = 0; +let trackingUserInput = false; +function trackUserInput() { + if (trackingUserInput || typeof window === "undefined") return; + trackingUserInput = true; + const record = () => { + lastUserInputAt = performance.now(); + }; + for (const type of ["keydown", "pointerdown", "wheel"] as const) { + window.addEventListener(type, record, { capture: true, passive: true }); + } +} + /** * The single chat surface behind both `/draft/$draftId` and * `/$environmentId/$threadId`. Each draft gets its own ChatView instance (so @@ -55,8 +82,11 @@ const KeptChatView = memo(ChatView); * The last few server threads stay rendered inside hidden `` * boundaries, so a revisit only reveals the view it already painted. Hidden * views keep their DOM and state; React disconnects their effects and store - * subscriptions until they are shown again. A draft's view is dropped once - * another view is shown, as before. + * subscriptions until they are shown again. A first open switches an + * existing view to the new thread, as the single shared view used to, and + * keeps the thread it showed in a new hidden view once the app is idle (see + * `resolveKeptThreadViews`). A draft's view is dropped once another view is + * shown, as before. * * Rendered by KeptChatSurface rather than by the two leaf routes, since an * element only survives a route swap when the same parent renders it. @@ -199,10 +229,54 @@ export const ThreadRouteView = memo(function ThreadRouteView({ }; } const [keptViews, setKeptViews] = useState>([]); - const nextKeptViews = resolveKeptThreadViews(keptViews, activeView); + const [releasedView, setReleasedView] = useState(null); + const keptUpdate = resolveKeptThreadViews(keptViews, activeView); + const nextKeptViews = keptUpdate.views; if (nextKeptViews !== keptViews) { setKeptViews(nextKeptViews); + if (keptUpdate.released !== null) setReleasedView(keptUpdate.released); } + // A first open took over the shown view. The thread it showed gets a view + // of its own again, which warms up out of sight (see KeptViewFrame) so a + // revisit only reveals it. Mounting it is a long task, so it waits until + // the shown thread has loaded and the user has left the app alone. + const shownThreadStatus = useThreadStatus(target.kind === "server" ? target.threadRef : null); + const shownThreadLoaded = + target.kind !== "server" || shownThreadStatus === "live" || shownThreadStatus === "deleted"; + useEffect(trackUserInput, []); + useEffect(() => { + if (releasedView === null || !shownThreadLoaded) return; + let timeout: ReturnType | undefined; + let idle: number | undefined; + const keep = () => + startTransition(() => { + setKeptViews((current) => keepReleasedThreadView(current, releasedView)); + setReleasedView(null); + }); + const attempt = () => { + const quietFor = performance.now() - lastUserInputAt; + if (quietFor < WARM_UP_QUIET_MS) { + timeout = setTimeout(attempt, WARM_UP_QUIET_MS - quietFor); + } else if (typeof requestIdleCallback === "function") { + idle = requestIdleCallback(keep, { timeout: 2_000 }); + } else { + keep(); + } + }; + timeout = setTimeout(attempt, WARM_UP_QUIET_MS); + return () => { + clearTimeout(timeout); + if (idle !== undefined) cancelIdleCallback(idle); + }; + }, [releasedView, shownThreadLoaded]); + const finishWarming = useCallback((viewKey: string) => { + setKeptViews((current) => finishWarmingThreadView(current, viewKey)); + }, []); + useLayoutEffect(() => { + keptThreadKeys = new Set( + keptViews.flatMap((view) => (view.draftId === null ? [view.threadKey] : [])), + ); + }, [keptViews]); // One lifetime per kept view, released once the view is gone for good. const lifetimesRef = useRef(new Map>()); const insetRef = useRef(null); @@ -261,24 +335,33 @@ export const ThreadRouteView = memo(function ThreadRouteView({ className="h-svh min-h-0 overflow-hidden overscroll-y-none md:h-dvh" > {nextKeptViews.map((view) => ( - - - {view.draftId === null ? ( - - ) : ( - - )} - + + + + {view.draftId === null ? ( + + ) : ( + + )} + + ))} @@ -295,23 +378,29 @@ export function KeptChatSurface() { strict: false, select: (params) => threadRouteTargetKey(resolveThreadRouteTarget(params)), }); - // The URL and sidebar selection commit with the click; the thread view - // swap follows in a transition, so opening a thread never holds that frame. + // Revealing a kept thread follows the click in a transition, so the URL and + // sidebar selection paint at once. A first open renders with the click, as + // the single shared view did: deferring it only delays its data render. const deferredTargetKey = useDeferredValue(targetKey); - // Start loading the thread with the click; its view mounts a transition later. + const targetThreadKey = targetKey?.startsWith("server:") + ? targetKey.slice("server:".length) + : null; + const reveal = targetThreadKey !== null && keptThreadKeys.has(targetThreadKey); + const shownKey = reveal ? deferredTargetKey : targetKey; + // Start loading the thread with the click, ahead of its view. useEffect(() => { const urgentTarget = threadRouteTargetFromKey(targetKey); if (urgentTarget?.kind !== "server") return; return appAtomRegistry.mount(environmentThreadDetails.threadAtom(urgentTarget.threadRef)); }, [targetKey]); - const [shownTargetKey, setShownTargetKey] = useState(deferredTargetKey); - if (deferredTargetKey !== null && deferredTargetKey !== shownTargetKey) { - setShownTargetKey(deferredTargetKey); + const [shownTargetKey, setShownTargetKey] = useState(shownKey); + if (shownKey !== null && shownKey !== shownTargetKey) { + setShownTargetKey(shownKey); } const target = useMemo(() => threadRouteTargetFromKey(shownTargetKey), [shownTargetKey]); if (target === null) return null; return ( - + ); diff --git a/apps/web/src/components/keptView.tsx b/apps/web/src/components/keptView.tsx index c90083cfd98e9..896e09bdaa9b3 100644 --- a/apps/web/src/components/keptView.tsx +++ b/apps/web/src/components/keptView.tsx @@ -6,10 +6,12 @@ import { memo, useContext, useEffect, + useLayoutEffect, useReducer, useRef, useState, type DependencyList, + type ReactNode, type RefObject, } from "react"; @@ -239,3 +241,50 @@ export const KeptEditorContent = memo( }, ), ); + +/** True while a kept view warms up out of sight; it must not act as the shown thread. */ +export const KeptViewWarmingContext = createContext(false); + +/** + * Frames one kept view. While warming, the view lays out at full size but + * out of sight, and cannot be focused or clicked; `onWarm` runs two frames + * after it mounted, once its list has settled. + */ +export function KeptViewFrame({ + viewKey, + warming, + onWarm, + children, +}: { + viewKey: string; + warming: boolean; + onWarm: (viewKey: string) => void; + children: ReactNode; +}) { + const onWarmRef = useRef(onWarm); + onWarmRef.current = onWarm; + useLayoutEffect(() => { + if (!warming) return; + let second = 0; + const first = requestAnimationFrame(() => { + second = requestAnimationFrame(() => onWarmRef.current(viewKey)); + }); + return () => { + cancelAnimationFrame(first); + cancelAnimationFrame(second); + }; + }, [viewKey, warming]); + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index a812872b0b781..c127ad2edbe29 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -7,6 +7,8 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams, resolveActiveThreadRouteRef, + finishWarmingThreadView, + keepReleasedThreadView, resolveKeptThreadViews, resolveThreadRouteRenderState, resolveThreadRouteRef, @@ -176,44 +178,76 @@ describe("resolveKeptThreadViews", () => { threadId: ThreadId.make(id), draftId: null, }); - const keep = ( - views: ReadonlyArray, - active: Omit | null, - limit?: number, - ) => resolveKeptThreadViews(views, active, limit); - - it("adds each visited thread in place and returns the same list when nothing changes", () => { - const first = keep([], serverView("a")); - const second = keep(first, serverView("b")); - expect(second.map((view) => view.threadId)).toEqual(["a", "b"]); - expect(keep(second, serverView("b"))).toBe(second); - }); - - it("revisits a kept thread without moving it", () => { - const views = keep(keep(keep([], serverView("a")), serverView("b")), serverView("a")); - expect(views.map((view) => view.threadId)).toEqual(["a", "b"]); - expect(views[0]!.visit).toBeGreaterThan(views[1]!.visit); - }); - - it("evicts the least recently shown thread past the limit", () => { - let views = keep([], serverView("a"), 2); - views = keep(views, serverView("b"), 2); - views = keep(views, serverView("a"), 2); - views = keep(views, serverView("c"), 2); - expect(views.map((view) => view.threadId)).toEqual(["a", "c"]); + // Opens each thread in turn, keeping released threads and finishing their + // warm-up right away, as idle time would. + const visit = (ids: ReadonlyArray, limit?: number) => + ids.reduce>((views, id) => { + const update = resolveKeptThreadViews(views, serverView(id), limit); + if (update.released === null) return update.views; + const kept = keepReleasedThreadView(update.views, update.released, limit); + const warming = kept.find((view) => view.warming === true); + return warming === undefined ? kept : finishWarmingThreadView(kept, warming.key); + }, []); + + it("returns the same list when the shown thread is shown again", () => { + const views = visit(["a"]); + expect(resolveKeptThreadViews(views, serverView("a")).views).toBe(views); + }); + + it("opens a new thread in the shown view and releases the thread it showed", () => { + const first = resolveKeptThreadViews([], serverView("a")).views; + const update = resolveKeptThreadViews(first, serverView("b")); + expect(update.views.map((view) => [view.key, view.threadId])).toEqual([ + ["view:env-1:a:1", "b"], + ]); + expect(update.released?.threadId).toBe("a"); + }); + + it("keeps a released thread in a warming view, then reveals it on a revisit", () => { + const first = resolveKeptThreadViews([], serverView("a")).views; + const update = resolveKeptThreadViews(first, serverView("b")); + const kept = keepReleasedThreadView(update.views, update.released!); + expect(kept.map((view) => [view.threadId, view.warming === true])).toEqual([ + ["b", false], + ["a", true], + ]); + const revisit = resolveKeptThreadViews(kept, serverView("a")); + expect(revisit.released).toBeNull(); + expect(revisit.views.map((view) => view.key)).toEqual(kept.map((view) => view.key)); + expect(revisit.views.find((view) => view.threadId === "a")?.warming).toBe(false); + }); + + it("drops the least recently shown hidden view to keep a released thread in a full pool", () => { + const views = visit(["a", "b", "c"], 3); + expect(views.map((view) => view.threadId).toSorted()).toEqual(["a", "b", "c"]); + const next = visit(["a", "b", "c", "d"], 3); + expect(next.map((view) => view.threadId).toSorted()).toEqual(["b", "c", "d"]); + }); + + it("skips keeping a released thread that is shown again", () => { + const first = resolveKeptThreadViews([], serverView("a")).views; + const update = resolveKeptThreadViews(first, serverView("b")); + const back = resolveKeptThreadViews(update.views, serverView("a")); + expect(keepReleasedThreadView(back.views, update.released!)).toBe(back.views); }); it("keeps a promoted draft's view, and its key, on the thread route", () => { const draftId = DraftId.make("draft-1"); - const draft = keep([], { ...serverView("t"), key: draftId, draftId }); - const promoted = keep(draft, serverView("t")); + const draft = resolveKeptThreadViews([], { ...serverView("t"), key: draftId, draftId }).views; + const promoted = resolveKeptThreadViews(draft, serverView("t")).views; expect(promoted).toHaveLength(1); expect(promoted[0]).toMatchObject({ key: draftId, draftId: null, threadId: "t" }); }); it("drops a draft's view once another view is shown", () => { const draftId = DraftId.make("draft-1"); - const views = keep(keep([], serverView("a")), { ...serverView("t"), key: draftId, draftId }); - expect(keep(views, serverView("a")).map((view) => view.key)).toEqual(["env-1:a"]); + const views = resolveKeptThreadViews(visit(["a"]), { + ...serverView("t"), + key: draftId, + draftId, + }).views; + expect( + resolveKeptThreadViews(views, serverView("a")).views.map((view) => view.threadId), + ).toEqual(["a"]); }); }); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 60b453829facc..4a6121712145b 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -135,65 +135,155 @@ export interface KeptThreadView { readonly draftId: DraftId | null; /** Visit order, used to evict the least recently shown view. */ readonly visit: number; + /** + * Mounted in idle time to keep a thread whose view a first open took over. + * It lays out once, out of sight, before it is hidden like any kept view. + */ + readonly warming?: boolean; } /** Recently shown server threads stay rendered, so a revisit only reveals them. */ export const KEPT_THREAD_VIEW_LIMIT = 4; +function retarget( + view: KeptThreadView, + active: Omit, + visit: number, +): KeptThreadView { + return { + ...view, + environmentId: active.environmentId, + threadId: active.threadId, + threadKey: active.threadKey, + draftId: active.draftId, + visit, + }; +} + +function leastRecentlyShown(views: ReadonlyArray): KeptThreadView | null { + return views.reduce( + (oldest, view) => (oldest !== null && oldest.visit <= view.visit ? oldest : view), + null, + ); +} + +export interface KeptThreadViewsUpdate { + readonly views: ReadonlyArray; + /** The thread the shown view stopped showing; keep it with `keepReleasedThreadView`. */ + readonly released: KeptThreadView | null; +} + /** - * Adds or refreshes the active view and evicts the least recently shown - * ones past `limit`. Entries never reorder: moving a rendered view in the - * DOM would drop its scroll position. Returns `current` when nothing changed. + * Shows `active` in a kept view. A revisit reveals the view that already + * shows it. A first open switches the shown view to the new thread, as the + * single shared view always did, and returns the thread it showed as + * `released`; mounting a view on the way there would make the open slower. + * From a draft, a hidden view is reused if there is one. Drafts get their own + * view and are dropped once left. Entries never reorder: moving a rendered + * view in the DOM would drop its scroll position. Returns `current` when + * nothing changed. */ export function resolveKeptThreadViews( current: ReadonlyArray, active: Omit | null, limit = KEPT_THREAD_VIEW_LIMIT, -): ReadonlyArray { +): KeptThreadViewsUpdate { + const unchanged = { views: current, released: null }; const lastVisit = current.reduce((max, view) => Math.max(max, view.visit), 0); // A server thread reuses whichever view already shows it, which is how a // promoted draft's view carries over to the thread route. - const activeIndex = + const existing = active === null - ? -1 - : current.findIndex((view) => + ? undefined + : current.find((view) => active.draftId === null ? view.threadKey === active.threadKey : view.key === active.key, ); - let next = current.filter((view, index) => view.draftId === null || index === activeIndex); - if (active !== null) { - const existing = activeIndex === -1 ? undefined : current[activeIndex]; - if (existing === undefined) { - next = [...next, { ...active, visit: lastVisit + 1 }]; - } else if ( - existing.draftId !== active.draftId || - existing.environmentId !== active.environmentId || - existing.threadId !== active.threadId || - existing.visit !== lastVisit - ) { - const refreshed: KeptThreadView = { - ...existing, - environmentId: active.environmentId, - threadId: active.threadId, - threadKey: active.threadKey, - draftId: active.draftId, - visit: existing.visit === lastVisit ? lastVisit : lastVisit + 1, + const servers = current.filter((view) => view.draftId === null && view !== existing); + const same = (views: ReadonlyArray) => + views.length === current.length && views.every((view, index) => view === current[index]); + if (active === null) { + return same(servers) ? unchanged : { views: servers, released: null }; + } + if (existing !== undefined) { + const visit = existing.visit === lastVisit ? lastVisit : lastVisit + 1; + const refreshed = + existing.draftId === active.draftId && + existing.environmentId === active.environmentId && + existing.threadId === active.threadId && + existing.visit === visit && + existing.warming !== true + ? existing + : { ...retarget(existing, active, visit), warming: false }; + const views = current.flatMap((view) => + view === existing ? [refreshed] : view.draftId === null ? [view] : [], + ); + return same(views) ? unchanged : { views, released: null }; + } + const visit = lastVisit + 1; + const shown = current.find((view) => view.visit === lastVisit && view.warming !== true); + if (active.draftId === null) { + if (shown !== undefined && shown.draftId === null) { + return { + views: servers.map((view) => (view === shown ? retarget(view, active, visit) : view)), + released: shown, + }; + } + const recycled = leastRecentlyShown(servers.filter((view) => view.warming !== true)); + if (recycled !== null) { + return { + views: servers.map((view) => (view === recycled ? retarget(view, active, visit) : view)), + released: null, }; - next = next.map((view) => (view === existing ? refreshed : view)); } } - while (next.length > limit) { - const evicted = next.reduce( - (oldest, view) => - (active !== null && view.threadKey === active.threadKey) || - (oldest !== null && oldest.visit <= view.visit) - ? oldest - : view, - null, - ); + // Nothing to switch (the first view, or a new draft): mount one. + let views: ReadonlyArray = [ + ...servers, + { ...active, key: active.draftId ?? `view:${active.threadKey}:${visit}`, visit }, + ]; + while (views.length > limit) { + const evicted = leastRecentlyShown(views.slice(0, -1)); if (evicted === null) break; - next = next.filter((view) => view !== evicted); + views = views.filter((view) => view !== evicted); + } + return { views, released: null }; +} + +/** + * Keeps a released thread in a new view that warms up in idle time, making + * room by dropping the least recently shown hidden view. Skipped once the + * thread is shown again. + */ +export function keepReleasedThreadView( + current: ReadonlyArray, + released: KeptThreadView, + limit = KEPT_THREAD_VIEW_LIMIT, +): ReadonlyArray { + if (limit < 2 || current.some((view) => view.threadKey === released.threadKey)) return current; + const lastVisit = current.reduce((max, view) => Math.max(max, view.visit), 0); + let views = current; + while (views.length >= limit) { + const evicted = leastRecentlyShown(views.filter((view) => view.visit !== lastVisit)); + if (evicted === null) return current; + views = views.filter((view) => view !== evicted); } - return next.length === current.length && next.every((view, index) => view === current[index]) - ? current - : next; + return [ + ...views, + { + ...released, + key: `kept:${released.threadKey}:${released.visit}`, + draftId: null, + warming: true, + }, + ]; +} + +/** Hides a warmed view like any kept one. */ +export function finishWarmingThreadView( + current: ReadonlyArray, + key: string, +): ReadonlyArray { + return current.some((view) => view.key === key && view.warming === true) + ? current.map((view) => (view.key === key ? { ...view, warming: false } : view)) + : current; } From e3a46f461cd679c277d8d00d1602ad19802476c6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 01:16:45 +0000 Subject: [PATCH 026/241] perf(web): skip effect definition stacks while the app graph evaluates Every module-level Effect.fn and labeled atom captured a stack trace during import. Production boots evaluate main's graph with Error.stackTraceLimit at 0 and restore it before startup code runs. --- apps/web/src/bootstrap.ts | 9 ++++++++- apps/web/src/lib/bootStackTraces.ts | 21 +++++++++++++++++++++ apps/web/src/main.tsx | 3 +++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/bootStackTraces.ts diff --git a/apps/web/src/bootstrap.ts b/apps/web/src/bootstrap.ts index 9a3d4a03b3caf..540cad8274bf7 100644 --- a/apps/web/src/bootstrap.ts +++ b/apps/web/src/bootstrap.ts @@ -1,5 +1,12 @@ import { showBootError } from "./lib/bootError"; +import { restoreStackTraces, suspendStackTracesForAppImport } from "./lib/bootStackTraces"; // Bundled dev can move UI code into shared chunks. Load it only after this // entry runs the React refresh preamble, and catch failures before React mounts. -void import("./main").then(({ startup }) => startup).catch(showBootError); +suspendStackTracesForAppImport(); +void import("./main") + .then(({ startup }) => startup) + .catch((error: unknown) => { + restoreStackTraces(); + showBootError(error); + }); diff --git a/apps/web/src/lib/bootStackTraces.ts b/apps/web/src/lib/bootStackTraces.ts new file mode 100644 index 0000000000000..a6b3398b2b9df --- /dev/null +++ b/apps/web/src/lib/bootStackTraces.ts @@ -0,0 +1,21 @@ +let suspendedLimit: number | undefined; + +/** + * Effect captures a definition-site stack for every module-level `Effect.fn` + * and labeled atom, which costs tens of milliseconds while the app graph + * evaluates. Those stacks only enrich debugging output, and Effect skips them + * when `Error.stackTraceLimit` is 0. Production boots evaluate the app graph + * without them; call sites keep their runtime stacks once the limit is back. + */ +export function suspendStackTracesForAppImport(): void { + if (!import.meta.env.PROD || suspendedLimit !== undefined) return; + suspendedLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 0; +} + +/** Restores stack traces; main calls this before its own startup code runs. */ +export function restoreStackTraces(): void { + if (suspendedLimit === undefined) return; + Error.stackTraceLimit = suspendedLimit; + suspendedLimit = undefined; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index af76a3311dd52..46d946e9f7cb0 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -14,7 +14,10 @@ import { } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; import { reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +import { restoreStackTraces } from "./lib/bootStackTraces"; +// Every import above has evaluated; startup code runs with full stacks. +restoreStackTraces(); prepareProviderAuthDelivery(); // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. From 2225b810fd921149c247b7d5ac7d43872b909655 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 01:16:45 +0000 Subject: [PATCH 027/241] perf(web): take the sidebar motion baseline in the next frame A commit that cannot animate no longer reads every row box, which forced a layout during each boot commit. --- .../web/src/components/Sidebar.motion.test.ts | 26 +++++++- apps/web/src/components/Sidebar.motion.ts | 64 ++++++++++++++----- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index 9e92c4d2c76e4..57f067fcdd605 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -65,11 +65,19 @@ class TestRow { }); } -function fixture(rows: TestRow[]) { +function fixture(rows: TestRow[], frames?: FrameRequestCallback[]) { const media = { matches: false }; const parent = { children: rows, - ownerDocument: { defaultView: { matchMedia: () => media } }, + ownerDocument: { + defaultView: { + matchMedia: () => media, + ...(frames && { + requestAnimationFrame: (callback: FrameRequestCallback) => frames.push(callback), + cancelAnimationFrame: () => {}, + }), + }, + }, getBoundingClientRect: () => ({ top: 0 }), append(node: TestRow) { parent.children.push(node); @@ -526,6 +534,20 @@ describe("sidebar list motion", () => { expect(rest.every((row) => row.animations.length === 1)).toBe(true); }); + it("reads a non-animating commit's baseline in the next frame", () => { + const frames: FrameRequestCallback[] = []; + const [a, b] = [new TestRow("a"), new TestRow("b")]; + const { motion, layout } = fixture([a, b], frames); + motion.update(false); + expect(frames).toHaveLength(1); + layout([b, a]); + frames.shift()!(0); + layout([a, b]); + motion.update(true); + expectMove(a, 83); + expectMove(b, -83); + }); + it("respects reduced motion while keeping the next baseline fresh", () => { const a = new TestRow("a"); const b = new TestRow("b"); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index dba6072e9d01e..14b211fa1df9a 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -23,15 +23,51 @@ function progress(animation: Animation) { export function createSidebarListMotion(parent: HTMLUListElement) { let positions: Map | null = null; let disposed = false; - const reducedMotion = parent.ownerDocument.defaultView?.matchMedia( - "(prefers-reduced-motion: reduce)", - ); + const view = parent.ownerDocument.defaultView; + const reducedMotion = view?.matchMedia("(prefers-reduced-motion: reduce)"); const running = new Map(); const entering = new Map(); const exiting = new Map(); // Visual tops at drag release, relative to the list, so the release // commit can glide every row from where dnd-kit left it into its slot. let released: Map | null = null; + let baselineFrame: number | null = null; + + const rows = () => + Array.from(parent.children).filter( + (node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node), + ); + const measure = () => + new Map( + rows().map((node) => [ + node, + { + top: node.offsetTop, + left: node.offsetLeft, + width: node.offsetWidth, + height: node.offsetHeight, + }, + ]), + ); + const cancelBaseline = () => { + if (baselineFrame !== null) view?.cancelAnimationFrame(baselineFrame); + baselineFrame = null; + }; + // Reading every row box during a commit forces a layout, and boot runs + // several commits before its first paint. A commit that cannot animate takes + // the next baseline in the following frame, which lays out anyway. + const scheduleBaseline = () => { + positions = null; + if (typeof view?.requestAnimationFrame !== "function") { + positions = measure(); + return; + } + if (baselineFrame !== null) return; + baselineFrame = view.requestAnimationFrame(() => { + baselineFrame = null; + if (!disposed && positions === null) positions = measure(); + }); + }; const remainingOffset = (node: HTMLElement) => { const current = running.get(node); @@ -106,6 +142,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const suspend = () => { for (const node of running.keys()) cancel(node); clearFades(); + cancelBaseline(); positions = null; released = null; }; @@ -132,19 +169,14 @@ export function createSidebarListMotion(parent: HTMLUListElement) { return { update(animate: boolean) { if (disposed) return; - const next = new Map( - Array.from(parent.children) - .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) - .map((node) => [ - node, - { - top: node.offsetTop, - left: node.offsetLeft, - width: node.offsetWidth, - height: node.offsetHeight, - }, - ]), - ); + if (released === null && (!animate || positions === null || reducedMotion?.matches)) { + clearFades(); + for (const node of running.keys()) cancel(node); + scheduleBaseline(); + return; + } + cancelBaseline(); + const next = measure(); let fadeCount = 0; if (positions !== null) { for (const [node, position] of positions) { From 3086d0c0214d29f3c6a78ddac24da8baa332785f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 01:16:45 +0000 Subject: [PATCH 028/241] perf(web): load the nightly notice without the settings row The startup notice imported the settings layout and QR code renderer with the About row, so every boot fetched and evaluated them. --- apps/web/src/components/NightlyMobileBeta.tsx | 82 +------------------ .../components/NightlyMobileBetaNotice.tsx | 79 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 3 +- apps/web/src/routes/__root.tsx | 10 +-- 4 files changed, 87 insertions(+), 87 deletions(-) create mode 100644 apps/web/src/components/NightlyMobileBetaNotice.tsx diff --git a/apps/web/src/components/NightlyMobileBeta.tsx b/apps/web/src/components/NightlyMobileBeta.tsx index 45e11c27e1595..3ea1e234a3d01 100644 --- a/apps/web/src/components/NightlyMobileBeta.tsx +++ b/apps/web/src/components/NightlyMobileBeta.tsx @@ -1,91 +1,17 @@ -import { parseSemver } from "@t3tools/shared/semver"; -import { useNavigate } from "@tanstack/react-router"; -import * as Schema from "effect/Schema"; -import { SmartphoneIcon } from "lucide-react"; -import { useEffect } from "react"; - -import { APP_VERSION, HOSTED_APP_CHANNEL } from "../branding"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; -import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; import { AndroidIcon, AppleIcon } from "./Icons"; +import { NIGHTLY_MOBILE_BETA_ROW_ID } from "./NightlyMobileBetaNotice"; import { SettingsRow } from "./settings/settingsLayout"; import { Button } from "./ui/button"; import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover"; import { QRCodeSvg } from "./ui/qr-code"; -import { toastManager } from "./ui/toast"; - -// Orchestrator V2 ships to Nightly first. The App Store and Google Play apps are -// still V1 and cannot connect to a V2 server, so Nightly users need the V2 beta -// app. Delete this file when the store apps move to V2. See #14871. - -/** True on Nightly desktop, `npx t3@nightly`, and the hosted Nightly app. */ -export const IS_NIGHTLY_BUILD = - parseSemver(APP_VERSION)?.prerelease[0] === "nightly" || HOSTED_APP_CHANNEL === "nightly"; +// The Nightly beta app links. See NightlyMobileBetaNotice.tsx; both go away +// when the store apps move to V2. const IOS_TESTFLIGHT_URL = "https://testflight.apple.com/join/XgaxaRtd"; const ANDROID_BETA_GROUP_URL = "https://groups.google.com/g/t3-code-v2-beta"; const ANDROID_PLAY_TESTING_URL = "https://play.google.com/apps/testing/com.t3tools.t3code"; -const ROW_ID = "nightly-mobile-beta"; -const NOTICE_DISMISSED_STORAGE_KEY = "t3code:nightly-mobile-beta-notice-dismissed:v1"; - -// Guards against a second toast from a remount or a Strict Mode effect replay. -let noticeShown = false; - -function isNoticeDismissed(): boolean { - try { - return getLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, Schema.Boolean) === true; - } catch { - return false; - } -} - -function dismissNotice() { - try { - setLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, true, Schema.Boolean); - } catch { - // Storage is unavailable. The notice shows again on the next launch. - } -} - -/** - * One-time Nightly toast that points to the beta mobile app. Any close (the - * corner button, swipe, Dismiss, or the action) hides it for good on this client. - */ -export function NightlyMobileBetaNotice() { - const navigate = useNavigate(); - - useEffect(() => { - if (!IS_NIGHTLY_BUILD || noticeShown || isNoticeDismissed()) return; - noticeShown = true; - const toastId = toastManager.add({ - title: "Nightly needs the beta mobile app", - description: - "Nightly uses the new orchestrator. The App Store and Google Play versions of T3 Code cannot connect to it.", - timeout: 0, - onClose: dismissNotice, - actionProps: { - children: "Get the beta app", - onClick: () => { - toastManager.close(toastId); - void navigate({ to: "/settings/general", hash: ROW_ID }); - }, - }, - data: { - leadingIcon: , - actionLayout: "stacked-end", - secondaryActionProps: { - children: "Dismiss", - onClick: () => toastManager.close(toastId), - }, - secondaryActionVariant: "ghost", - }, - }); - }, [navigate]); - - return null; -} - /** QR code plus a copy button for one beta link. */ function BetaLinkQr({ url, label }: { url: string; label: string }) { const { copyToClipboard, isCopied } = useCopyToClipboard(); @@ -105,7 +31,7 @@ function BetaLinkQr({ url, label }: { url: string; label: string }) { export function NightlyMobileBetaRow() { return ( { + if (!IS_NIGHTLY_BUILD || noticeShown || isNoticeDismissed()) return; + noticeShown = true; + const toastId = toastManager.add({ + title: "Nightly needs the beta mobile app", + description: + "Nightly uses the new orchestrator. The App Store and Google Play versions of T3 Code cannot connect to it.", + timeout: 0, + onClose: dismissNotice, + actionProps: { + children: "Get the beta app", + onClick: () => { + toastManager.close(toastId); + void navigate({ to: "/settings/general", hash: NIGHTLY_MOBILE_BETA_ROW_ID }); + }, + }, + data: { + leadingIcon: , + actionLayout: "stacked-end", + secondaryActionProps: { + children: "Dismiss", + onClick: () => toastManager.close(toastId), + }, + secondaryActionVariant: "ghost", + }, + }); + }, [navigate]); + + return null; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bee57d21b2531..16fcfea44b40c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -54,7 +54,8 @@ import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; -import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta"; +import { NightlyMobileBetaRow } from "../NightlyMobileBeta"; +import { IS_NIGHTLY_BUILD } from "../NightlyMobileBetaNotice"; import { CliCommandSettingsRow } from "./CliCommandSettingsRow"; import { canCheckForUpdate, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 25fb0abc1e26a..cfc210632e020 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,5 @@ import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice"; +import { NightlyMobileBetaNotice } from "../components/NightlyMobileBetaNotice"; import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { @@ -97,11 +98,6 @@ const ConnectOnboardingDialog = lazy(() => .then((module) => ({ default: module?.ConnectOnboardingDialog ?? renderNothing })) .catch(() => ({ default: renderNothing })), ); -const NightlyMobileBetaNotice = lazy(() => - import("../components/NightlyMobileBeta") - .then((module) => ({ default: module?.NightlyMobileBetaNotice ?? renderNothing })) - .catch(() => ({ default: renderNothing })), -); // Sign-ins are observed here, outside the lazy wizard chunk, so one that // completes while the chunk loads still opens the wizard. @@ -285,9 +281,7 @@ function RootRouteView() { ) : null} {primaryEnvironmentAuthenticated ? : null} {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */} - - - + {appShell} {/* Above the router: a theme draft is judged by walking the app, so the editor has to survive navigation away from settings. */} From 8792430badfaced8e1368e898c21da4dc83c1c7e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 01:16:45 +0000 Subject: [PATCH 029/241] perf(web): resolve locale data on first use Week start and usage number formatters loaded ICU data at import time; they now resolve when the snooze calendar or usage page needs them. --- .../web/src/components/CustomSnoozeDialog.tsx | 3 ++- apps/web/src/timestampFormat.test.ts | 4 ++-- apps/web/src/timestampFormat.ts | 13 ++++++++-- packages/shared/src/usageFormat.ts | 24 +++++++++++-------- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 5b840e2eb7c5e..1b09def7a627e 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -8,7 +8,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { Button } from "./ui/button"; import { CalendarIcon } from "lucide-react"; -import { weekStartsOn } from "../timestampFormat"; +import { getWeekStartsOn } from "../timestampFormat"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; @@ -72,6 +72,7 @@ function CustomSnoozeDialog() { const [amount, setAmount] = useState("2"); const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); const [error, setError] = useState(null); + const weekStartsOn = getWeekStartsOn(); const input: CustomSnoozeInput = mode === "date" ? { mode, date: localSnoozeDate(date), time } : { mode, amount, unit }; return ( diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 75fe0d3b558ca..34c7c7e3cf232 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -75,8 +75,8 @@ describe("resolveWeekStartsOn", () => { it("follows the locale the desktop host reports", async () => { vi.stubGlobal("window", { desktopBridge: { getSystemLocale: () => "en-GB" } }); vi.resetModules(); - const { weekStartsOn } = await import("./timestampFormat"); - expect(weekStartsOn).toBe(1); + const { getWeekStartsOn } = await import("./timestampFormat"); + expect(getWeekStartsOn()).toBe(1); vi.unstubAllGlobals(); vi.resetModules(); }); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 7803bdc1b6f62..4fe6185b35611 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -79,8 +79,17 @@ export function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | } } -/** Week start for calendars, from the same locale timestamps are shown in. */ -export const weekStartsOn = resolveWeekStartsOn(timestampLocale); +let cachedWeekStartsOn: { readonly value: WeekdayIndex | undefined } | null = null; + +/** + * Week start for calendars, from the same locale timestamps are shown in. + * Resolved on first use: reading locale week data costs a few milliseconds + * that startup does not need. + */ +export function getWeekStartsOn(): WeekdayIndex | undefined { + cachedWeekStartsOn ??= { value: resolveWeekStartsOn(timestampLocale) }; + return cachedWeekStartsOn.value; +} const timestampFormatterCache = new Map(); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 83b2d53fb99fb..9c51d27da2890 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -8,21 +8,25 @@ import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools import type { UsageContractMismatch } from "./usageMerge.ts"; -const CURRENCY = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}); +// Created on first use: the first Intl.NumberFormat loads ICU number data, which +// app startup imports this module without needing. +let currency: Intl.NumberFormat | undefined; +let integer: Intl.NumberFormat | undefined; -const INTEGER = new Intl.NumberFormat("en-US"); +const integerFormat = () => (integer ??= new Intl.NumberFormat("en-US")); export function formatUsd(value: number): string { - return CURRENCY.format(value); + currency ??= new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + return currency.format(value); } export function formatCount(value: number): string { - return INTEGER.format(Math.round(value)); + return integerFormat().format(Math.round(value)); } /** @@ -35,7 +39,7 @@ export function formatTokens(value: number): string { if (abs >= 1e9) return `${trim(value / 1e9)}B`; if (abs >= 1e6) return `${trim(value / 1e6)}M`; if (abs >= 1e3) return `${trim(value / 1e3)}K`; - return INTEGER.format(Math.round(value)); + return integerFormat().format(Math.round(value)); } function trim(value: number): string { From a1ddc034f9c5814b5b7e2c24389aa127789a4101 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 01:16:45 +0000 Subject: [PATCH 030/241] perf(client): keep equal server configs and projects stable across boot Boot hands the same config from cache to session to live projection, and empty theme or usage-source events added undefined keys. Each handoff gave every reader a new object and rerendered the sidebar and shortcuts. Equal configs and unchanged project shells now keep their identity. --- .../client-runtime/src/state/server.test.ts | 18 +++++++++ packages/client-runtime/src/state/server.ts | 9 ++++- .../src/state/serverConfigProjection.ts | 40 +++++++++++-------- .../src/state/shellReducer.test.ts | 13 ++++++ .../client-runtime/src/state/shellReducer.ts | 8 +++- 5 files changed, 69 insertions(+), 19 deletions(-) diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 330df548ba0f7..af32630dfce86 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -10,6 +10,7 @@ import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; @@ -458,6 +459,23 @@ describe("server state projection", () => { expect(Option.getOrThrow(unpublished).config.environmentThemes).toBeUndefined(); }); + it("keeps an equal config when a machine publishes no themes or usage sources", () => { + const snapshot = Option.getOrThrow( + applyServerConfigProjection(Option.none(), { version: 1, type: "snapshot", config: CONFIG }), + ); + const themes = applyServerConfigProjection(Option.some(snapshot), { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }); + const sources = applyServerConfigProjection(themes, { + version: 1, + type: "usageLimitSourcesUpdated", + payload: { sources: [] }, + }); + expect(Equal.equals(Option.getOrThrow(sources).config, snapshot.config)).toBe(true); + }); + // A snapshot never carries published themes, so taking it wholesale would // clear them on every reconnect and repaint anyone wearing one. it("keeps published themes across a reconnect snapshot", () => { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 1d49fd71f70c4..2844c64c6911c 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -12,6 +12,7 @@ import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; @@ -669,7 +670,13 @@ export function createServerEnvironmentAtoms( projection, get(options.initialConfigValueAtom(environmentId)), ); - }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); + }).pipe( + // Boot hands the same config over from the cache, the session, and the + // live projection. An equal config keeps its identity, so settings, + // keybindings, and every reader do not rerender for each handoff. + Atom.withEquality(Equal.equals), + Atom.withLabel(`environment-data:server:config:${environmentId}`), + ); }); const updateStateValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts index 3c1e5c68af5a2..35abfafdbcbc6 100644 --- a/packages/client-runtime/src/state/serverConfigProjection.ts +++ b/packages/client-runtime/src/state/serverConfigProjection.ts @@ -90,23 +90,31 @@ export function applyServerConfigProjection( latestEvent: event, source: "live", })); + // An empty set omits the key, as a cached config does, so an unchanged + // config stays equal and keeps its identity downstream. case "environmentThemesUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, - }, - latestEvent: event, - source: "live", - })); + return Option.map(current, (projection) => { + const { environmentThemes: _previous, ...config } = projection.config; + return { + config: + event.payload.themes.length > 0 + ? { ...config, environmentThemes: event.payload.themes } + : config, + latestEvent: event, + source: "live", + }; + }); case "usageLimitSourcesUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - usageLimitSources: event.payload.sources.length > 0 ? event.payload.sources : undefined, - }, - latestEvent: event, - source: "live", - })); + return Option.map(current, (projection) => { + const { usageLimitSources: _previous, ...config } = projection.config; + return { + config: + event.payload.sources.length > 0 + ? { ...config, usageLimitSources: event.payload.sources } + : config, + latestEvent: event, + source: "live", + }; + }); } } diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index b7a69b2a51522..219ea312d2672 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -148,6 +148,19 @@ describe("applyShellStreamEvent", () => { expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity); }); + it("keeps the object of a project a full snapshot left unchanged", () => { + const prior = { ...v2Project, repositoryIdentity }; + const next = mergeShellSnapshotProjects( + { ...v2ShellSnapshot, projects: [prior] }, + { + ...v2ShellSnapshot, + snapshotSequence: 2, + projects: [{ ...v2Project, repositoryIdentity: structuredClone(repositoryIdentity) }], + }, + ); + expect(next.projects[0]).toBe(prior); + }); + it("authoritative lower-sequence reset replaces client-ahead structural state", () => { const previous = { ...v2ShellSnapshot, diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index f99f5e7681a42..d115cb15621ca 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -1,13 +1,14 @@ import { + OrchestrationProjectShell, OrchestrationV2ThreadShell, ThreadPullRequestLink, - type OrchestrationProjectShell, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; const sameThreadShell = Schema.toEquivalence(OrchestrationV2ThreadShell); +const sameProjectShell = Schema.toEquivalence(OrchestrationProjectShell); const sameLinks = Schema.toEquivalence(Schema.UndefinedOr(Schema.Array(ThreadPullRequestLink))); /** Whether two rows' pull request links are equal, including both absent. */ @@ -127,7 +128,10 @@ export function mergeShellSnapshotProjects( if (resolvedRootSet?.has(project.workspaceRoot) === true) { return project; } - return retainRepositoryIdentity(prior, project); + const merged = retainRepositoryIdentity(prior, project); + // An unchanged project keeps its object, so the cached-to-live handoff + // does not rerender every project reader. + return prior !== undefined && sameProjectShell(prior, merged) ? prior : merged; }), }; } From a5b4d285f7ff8bb10b7d72999f987249d124b9f5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 01:16:45 +0000 Subject: [PATCH 031/241] perf(web): stop chat view rerenders from fresh empty selections Two per-render atoms returned new values for unchanged state, which rendered the whole chat view again before the composer was ready. The citation location selector already returns a string. --- .../chat/useAutoBalanceUpdateBanner.tsx | 24 ++++++++--- .../src/hooks/useLoadBalancedEnvironment.ts | 41 +++++++++++++------ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx index 47ea5fe7c2787..d7d798c72976d 100644 --- a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx +++ b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx @@ -26,18 +26,30 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; import { ComposerServerUpdateIcon } from "./ComposerServerUpdateStatus"; +interface EnvironmentUpdateState { + readonly environment: EnvironmentPresentation; + readonly state: ServerUpdateState; +} + +// Shared by every caller without machines. A fresh atom per render would be +// read before it is subscribed and produce a new empty array, which makes +// React render the chat view again after it commits. +const NO_ENVIRONMENT_UPDATE_STATES = Atom.make>([]); + /** Keep every machine's update visible while auto balance has no single update target. */ export function useAutoBalanceUpdateBanner( environments: readonly EnvironmentPresentation[], ): ComposerBannerStackItem | null { const statesAtom = useMemo( () => - Atom.make((get) => - environments.map((environment) => ({ - environment, - state: get(serverEnvironment.updateStateAtom(environment.environmentId)), - })), - ), + environments.length === 0 + ? NO_ENVIRONMENT_UPDATE_STATES + : Atom.make((get): ReadonlyArray => + environments.map((environment) => ({ + environment, + state: get(serverEnvironment.updateStateAtom(environment.environmentId)), + })), + ), [environments], ); const states = useAtomValue(statesAtom); diff --git a/apps/web/src/hooks/useLoadBalancedEnvironment.ts b/apps/web/src/hooks/useLoadBalancedEnvironment.ts index f3c77425e737a..6a7e89bd7e4fa 100644 --- a/apps/web/src/hooks/useLoadBalancedEnvironment.ts +++ b/apps/web/src/hooks/useLoadBalancedEnvironment.ts @@ -1,11 +1,24 @@ import { RegistryContext, useAtomValue } from "@effect/atom-react"; import { chooseLoadBalancedEnvironment } from "@t3tools/client-runtime/load-balancing"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, HostResourcesSnapshot } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { useCallback, useContext, useMemo } from "react"; import { serverEnvironment } from "../state/server"; +interface EnvironmentResources { + readonly environmentId: EnvironmentId; + readonly resources: HostResourcesSnapshot | null; + readonly receivedAt: number; + readonly pending: boolean; + readonly failed: boolean; +} + +// Shared by every caller without candidates. A fresh atom per render would be +// read before it is subscribed and produce a new empty array, which makes +// React render the chat view again after it commits. +const NO_ENVIRONMENT_RESOURCES = Atom.make>([]); + /** Only mounted for unresolved automatic drafts, so idle clients do not poll hosts. */ export function useLoadBalancedEnvironment( environmentIds: readonly EnvironmentId[], @@ -22,18 +35,20 @@ export function useLoadBalancedEnvironment( ); const resourcesAtom = useMemo( () => - Atom.make((get) => - environmentIds.map((environmentId) => { - const result = get(serverEnvironment.hostResources({ environmentId, input: {} })); - return { - environmentId, - resources: result._tag === "Success" ? result.value : null, - receivedAt: result._tag === "Success" ? result.timestamp : 0, - pending: result._tag === "Initial" || result.waiting, - failed: result._tag === "Failure", - }; - }), - ), + environmentIds.length === 0 + ? NO_ENVIRONMENT_RESOURCES + : Atom.make((get): ReadonlyArray => + environmentIds.map((environmentId) => { + const result = get(serverEnvironment.hostResources({ environmentId, input: {} })); + return { + environmentId, + resources: result._tag === "Success" ? result.value : null, + receivedAt: result._tag === "Success" ? result.timestamp : 0, + pending: result._tag === "Initial" || result.waiting, + failed: result._tag === "Failure", + }; + }), + ), [environmentIds], ); const resources = useAtomValue(resourcesAtom); From 7c5923c2bfa6ae8c2cbc8b7b4c02ec0f662801ad Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 13:19:45 +0000 Subject: [PATCH 032/241] perf(client): compare server configs with a plain structural walk and reuse resolved projects Effect's structural equality hashed every string of the config on each boot handoff. Plain data now compares by value with shared objects short-circuiting, and DateTime and Option keep their own equality. Repository identity enrichment also keeps unchanged project objects. --- packages/client-runtime/src/state/server.ts | 37 ++++++++++++++++++- .../client-runtime/src/state/shellReducer.ts | 3 +- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 2844c64c6911c..bbd2c4c8e8707 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -596,6 +596,41 @@ function serverWelcomeStateChanges(environmentId: EnvironmentId) { ); } +/** + * Plain data compares by value, sharing an object short-circuits, and values + * with their own equality, such as DateTime and Option, use it. Hashing or a + * schema equivalence walks a whole config several times over on a cold boot. + */ +function sameConfigValue(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) { + return false; + } + if (Array.isArray(left)) { + if (!Array.isArray(right) || left.length !== right.length) return false; + for (let index = 0; index < left.length; index++) { + if (!sameConfigValue(left[index], right[index])) return false; + } + return true; + } + if ( + Object.getPrototypeOf(left) !== Object.prototype || + Object.getPrototypeOf(right) !== Object.prototype + ) { + return Equal.equals(left, right); + } + const leftRecord = left as Record; + const rightRecord = right as Record; + const keys = Object.keys(leftRecord); + if (keys.length !== Object.keys(rightRecord).length) return false; + for (const key of keys) { + if (!Object.hasOwn(rightRecord, key) || !sameConfigValue(leftRecord[key], rightRecord[key])) { + return false; + } + } + return true; +} + export function resolveServerConfigValue( projection: ServerConfigProjection | null, initialConfig: ServerConfig | null, @@ -674,7 +709,7 @@ export function createServerEnvironmentAtoms( // Boot hands the same config over from the cache, the session, and the // live projection. An equal config keeps its identity, so settings, // keybindings, and every reader do not rerender for each handoff. - Atom.withEquality(Equal.equals), + Atom.withEquality(sameConfigValue), Atom.withLabel(`environment-data:server:config:${environmentId}`), ); }); diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index d115cb15621ca..c2cba12675879 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -110,7 +110,8 @@ export function mergeShellSnapshotProjects( return project; } if (resolvedRootSet?.has(project.workspaceRoot) === true) { - return { ...project, repositoryIdentity: candidate.repositoryIdentity }; + const resolved = { ...project, repositoryIdentity: candidate.repositoryIdentity }; + return sameProjectShell(project, resolved) ? project : resolved; } if (project.repositoryIdentity == null && candidate.repositoryIdentity != null) { return { ...project, repositoryIdentity: candidate.repositoryIdentity }; From 5c46ab816747d3aa794517612d25ada7f76473ad Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 02:15:47 +0000 Subject: [PATCH 033/241] perf(web): paint the first screen from a local boot snapshot The sidebar, composer labels and environment list waited for the connection runtime, IndexedDB, a schema decode and the server before the first screen could show them. The environment cache now also keeps its latest shell, server config and catalog in localStorage as plain JSON with DateTime and Duration markers. The next boot reads it synchronously and the catalog, shell and config atoms show it as cached data until live data replaces it; the first cache load reuses it instead of decoding IndexedDB again. Connecting waits until the sidebar and then the chat view have painted. --- apps/web/src/AppRoot.tsx | 3 + apps/web/src/bootSnapshot.ts | 361 ++++++++++++++++++++ apps/web/src/components/ThreadRouteView.tsx | 104 ++++-- apps/web/src/connection/catalog.ts | 258 +++++++++++++- apps/web/src/connection/storage.ts | 55 ++- apps/web/src/main.tsx | 4 + apps/web/src/state/server.ts | 29 +- apps/web/src/state/shell.ts | 119 ++++++- 8 files changed, 886 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/bootSnapshot.ts diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index 1ec12cfdd7129..5bcf11b297aba 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -1,8 +1,10 @@ import { RouterProvider } from "@tanstack/react-router"; +import { useEffect } from "react"; import { BrowserProfileReporter } from "./browser/BrowserProfileReporter"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; +import { releaseBootHoldAfterPaint, retainBootHold } from "./connection/catalog"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; @@ -12,6 +14,7 @@ import type { AppRouter } from "./router"; * share the same atom registry as routed UI. */ export function AppRoot({ router }: { readonly router: AppRouter }) { + useEffect(() => releaseBootHoldAfterPaint(retainBootHold()), []); return ( diff --git a/apps/web/src/bootSnapshot.ts b/apps/web/src/bootSnapshot.ts new file mode 100644 index 0000000000000..6790e83b45773 --- /dev/null +++ b/apps/web/src/bootSnapshot.ts @@ -0,0 +1,361 @@ +import type { EnvironmentId, OrchestrationV2ShellSnapshot, ServerConfig } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; + +/** + * The first screen's data, kept in localStorage so the first React commit can + * paint the real sidebar and composer before the auth check, the connection + * runtime, IndexedDB or the server answer anything. + * + * It mirrors the IndexedDB environment cache (connection/storage.ts writes + * both), so it is never more authoritative than that cache: consumers present + * it as cached data and the live connection replaces it. A document written by + * another build is ignored, because the stored values skip schema decoding. + */ + +const STORAGE_KEY = "t3code:boot-snapshot:v1"; +const FORMAT = 1; +// localStorage holds about 5M characters per origin and drafts share it. +const MAX_DOCUMENT_LENGTH = 2_500_000; +const DATE_MARKER = "$t3date"; +const DURATION_MARKER = "$t3duration"; + +export type BootCatalogEntryKind = "primary" | "bearer" | "relay" | "ssh"; + +export interface BootCatalogEntry { + readonly environmentId: EnvironmentId; + readonly kind: BootCatalogEntryKind; + readonly label: string; + readonly enabled: boolean; + readonly connectionId?: string; + readonly httpBaseUrl?: string; + readonly wsBaseUrl?: string; +} + +export interface BootSnapshot { + readonly savedAt: number; + readonly catalog: ReadonlyArray; + readonly shells: ReadonlyMap; + readonly serverConfigs: ReadonlyMap; +} + +// The hashed chunk URL changes with every build that could change these types. +const BUILD_ID = (() => { + try { + return new URL(import.meta.url).pathname; + } catch { + return "unknown"; + } +})(); + +function storage(): Storage | null { + try { + return typeof window === "undefined" ? null : window.localStorage; + } catch { + return null; + } +} + +class UnsupportedBootValue extends Error {} + +function encodeDuration(duration: Duration.Duration): number | string { + const value = duration.value; + switch (value._tag) { + case "Millis": + return value.millis; + case "Nanos": + return `${value.nanos}n`; + case "Infinity": + return "Infinity"; + default: + return "-Infinity"; + } +} + +function decodeDuration(encoded: unknown): Duration.Duration | null { + if (typeof encoded === "number") return Duration.millis(encoded); + if (encoded === "Infinity") return Duration.infinity; + if (encoded === "-Infinity") return Duration.negativeInfinity; + if (typeof encoded === "string" && /^-?\d+n$/.test(encoded)) { + return Duration.nanos(BigInt(encoded.slice(0, -1))); + } + return null; +} + +/** + * JSON with DateTime and Duration support. Values the reviver could not + * rebuild exactly (class instances, Options, bigints) abort the write instead + * of coming back as plain objects. + */ +function replacer(this: Record, key: string, value: unknown): unknown { + const raw = this[key]; + if (typeof raw === "object" && raw !== null) { + if (DateTime.isDateTime(raw)) { + if (!DateTime.isUtc(raw)) throw new UnsupportedBootValue("zoned DateTime"); + return { [DATE_MARKER]: DateTime.toEpochMillis(raw) }; + } + if (Duration.isDuration(raw)) return { [DURATION_MARKER]: encodeDuration(raw) }; + const prototype = Object.getPrototypeOf(raw); + if (prototype !== Object.prototype && prototype !== Array.prototype && prototype !== null) { + throw new UnsupportedBootValue(`unsupported object at ${key}`); + } + return value; + } + if (typeof raw === "bigint" || typeof raw === "symbol" || typeof raw === "function") { + throw new UnsupportedBootValue(`unsupported ${typeof raw} at ${key}`); + } + if (typeof raw === "number" && !Number.isFinite(raw)) { + throw new UnsupportedBootValue(`non-finite number at ${key}`); + } + return value; +} + +function reviver(_key: string, value: unknown): unknown { + if (typeof value !== "object" || value === null || Array.isArray(value)) return value; + if (DATE_MARKER in value) { + const millis = (value as Record)[DATE_MARKER]; + if (typeof millis === "number" && Object.keys(value).length === 1) { + return DateTime.makeUnsafe(millis); + } + } else if (DURATION_MARKER in value && Object.keys(value).length === 1) { + return decodeDuration((value as Record)[DURATION_MARKER]) ?? value; + } + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isShellSnapshot(value: unknown): value is OrchestrationV2ShellSnapshot { + return ( + isRecord(value) && + typeof value.snapshotSequence === "number" && + Array.isArray(value.threads) && + Array.isArray(value.archivedThreads) && + Array.isArray(value.projects) + ); +} + +function isServerConfig(value: unknown): value is ServerConfig { + return ( + isRecord(value) && + isRecord(value.environment) && + typeof value.environment.environmentId === "string" && + Array.isArray(value.providers) && + isRecord(value.settings) + ); +} + +function isCatalogEntry(value: unknown): value is BootCatalogEntry { + return ( + isRecord(value) && + typeof value.environmentId === "string" && + typeof value.label === "string" && + typeof value.enabled === "boolean" && + (value.kind === "primary" || + value.kind === "bearer" || + value.kind === "relay" || + value.kind === "ssh") + ); +} + +function parseDocument(raw: string): BootSnapshot | null { + const parsed: unknown = JSON.parse(raw, reviver); + if ( + !isRecord(parsed) || + parsed.format !== FORMAT || + parsed.build !== BUILD_ID || + typeof parsed.savedAt !== "number" || + !Array.isArray(parsed.catalog) || + !isRecord(parsed.shells) || + !isRecord(parsed.serverConfigs) + ) { + return null; + } + const catalog = parsed.catalog.filter(isCatalogEntry); + const known = new Set(catalog.map((entry) => entry.environmentId)); + const shells = new Map(); + for (const [environmentId, snapshot] of Object.entries(parsed.shells)) { + if (known.has(environmentId as EnvironmentId) && isShellSnapshot(snapshot)) { + shells.set(environmentId as EnvironmentId, snapshot); + } + } + const serverConfigs = new Map(); + for (const [environmentId, config] of Object.entries(parsed.serverConfigs)) { + if ( + known.has(environmentId as EnvironmentId) && + isServerConfig(config) && + config.environment.environmentId === environmentId + ) { + serverConfigs.set(environmentId as EnvironmentId, config); + } + } + return { savedAt: parsed.savedAt, catalog, shells, serverConfigs }; +} + +let loaded = false; +let bootSnapshot: BootSnapshot | null = null; + +/** + * The snapshot this page booted with, read once. Null when there is none, it + * came from another build, or it was discarded. + */ +export function readBootSnapshot(): BootSnapshot | null { + if (loaded) return bootSnapshot; + loaded = true; + try { + const raw = storage()?.getItem(STORAGE_KEY) ?? null; + bootSnapshot = raw === null ? null : parseDocument(raw); + } catch { + bootSnapshot = null; + } + return bootSnapshot; +} + +// ── Writing ──────────────────────────────────────────────────────────────── + +let catalog: ReadonlyArray | null = null; +const shells = new Map(); +const serverConfigs = new Map(); +const encoded = new WeakMap(); +let seeded = false; +let flushTimer: ReturnType | null = null; +let listening = false; +let discarded = false; + +// The first write keeps the values this page booted with for environments the +// live data has not written yet. +function seedFromBootSnapshot() { + if (seeded) return; + seeded = true; + const initial = readBootSnapshot(); + if (initial === null) return; + catalog ??= initial.catalog; + for (const [environmentId, snapshot] of initial.shells) { + if (!shells.has(environmentId)) shells.set(environmentId, snapshot); + } + for (const [environmentId, config] of initial.serverConfigs) { + if (!serverConfigs.has(environmentId)) serverConfigs.set(environmentId, config); + } +} + +function encode(value: object): string | null { + const cached = encoded.get(value); + if (cached !== undefined) return cached; + let json: string | null; + try { + json = JSON.stringify(value, replacer); + } catch { + json = null; + } + encoded.set(value, json); + return json; +} + +function flush() { + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + if (discarded) return; + seedFromBootSnapshot(); + const store = storage(); + if (store === null || catalog === null) return; + const known = new Set( + catalog.filter((entry) => entry.enabled).map((entry) => entry.environmentId), + ); + const parts: string[] = []; + for (const [environmentId, snapshot] of shells) { + if (!known.has(environmentId)) continue; + const json = encode(snapshot); + if (json !== null) parts.push(`${JSON.stringify(environmentId)}:${json}`); + } + const configParts: string[] = []; + for (const [environmentId, config] of serverConfigs) { + if (!known.has(environmentId)) continue; + const json = encode(config); + if (json !== null) configParts.push(`${JSON.stringify(environmentId)}:${json}`); + } + const document = + `{"format":${FORMAT},"build":${JSON.stringify(BUILD_ID)},"savedAt":${Date.now()},` + + `"catalog":${JSON.stringify(catalog)},"shells":{${parts.join(",")}},` + + `"serverConfigs":{${configParts.join(",")}}}`; + try { + if (parts.length === 0 || document.length > MAX_DOCUMENT_LENGTH) { + store.removeItem(STORAGE_KEY); + return; + } + store.setItem(STORAGE_KEY, document); + } catch { + // Quota or a disabled store: an older document must not outlive this one. + try { + store.removeItem(STORAGE_KEY); + } catch { + // Nothing else to clean up. + } + } +} + +function scheduleFlush() { + if (typeof window === "undefined" || discarded) return; + if (!listening) { + listening = true; + window.addEventListener("pagehide", flush); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") flush(); + }); + } + if (flushTimer === null) flushTimer = setTimeout(flush, 1_500); +} + +/** Called after the environment shell cache saved this snapshot. */ +export function recordBootShell( + environmentId: EnvironmentId, + snapshot: OrchestrationV2ShellSnapshot, +): void { + if (shells.get(environmentId) === snapshot) return; + seedFromBootSnapshot(); + shells.set(environmentId, snapshot); + scheduleFlush(); +} + +/** Called after the server configuration cache saved this config. */ +export function recordBootServerConfig(environmentId: EnvironmentId, config: ServerConfig): void { + if (serverConfigs.get(environmentId) === config) return; + seedFromBootSnapshot(); + serverConfigs.set(environmentId, config); + scheduleFlush(); +} + +/** The live connection catalog, so the next boot knows which environments to show. */ +export function recordBootCatalog(entries: ReadonlyArray): void { + seedFromBootSnapshot(); + catalog = entries; + scheduleFlush(); +} + +/** Called when an environment's cache is cleared. */ +export function forgetBootEnvironment(environmentId: EnvironmentId): void { + seedFromBootSnapshot(); + const removedShell = shells.delete(environmentId); + const removedConfig = serverConfigs.delete(environmentId); + if (removedShell || removedConfig) scheduleFlush(); +} + +/** + * Drops the stored snapshot for good in this page, for example when the + * server no longer accepts this browser's session. + */ +export function discardBootSnapshot(): void { + discarded = true; + bootSnapshot = null; + loaded = true; + if (flushTimer !== null) clearTimeout(flushTimer); + flushTimer = null; + try { + storage()?.removeItem(STORAGE_KEY); + } catch { + // Nothing else to clean up. + } +} diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 9bbc7b6ae9f3b..057437032c564 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -36,6 +36,8 @@ import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; import { environmentThreadDetails } from "../state/threads"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { flushSync } from "react-dom"; +import { isBootHeld, releaseBootHoldAfterPaint, retainBootHold } from "../connection/catalog"; import { buildThreadRouteParams, finishWarmingThreadView, @@ -70,6 +72,48 @@ function trackUserInput() { } } +let bootChatViewDeferred = false; + +/** + * On a boot painting from its snapshot, the first frame shows the sidebar and + * this view's frame, and the chat view renders right after it, so its large + * first render does not hold back that frame. It renders in one task ahead of + * the connection's work: rendering it as a sliced transition let decoding + * interleave and made the composer later. + */ +function useBootDeferredChatView(): boolean { + const [ready, setReady] = useState(() => bootChatViewDeferred || !isBootHeld()); + const pendingReleaseRef = useRef<(() => void) | null>(null); + useEffect(() => { + if (ready) return; + bootChatViewDeferred = true; + const release = retainBootHold(); + let mounted = false; + const mount = () => { + if (mounted) return; + mounted = true; + // Released after the chat view's own commit paints, or on unmount. + pendingReleaseRef.current = release; + flushSync(() => setReady(true)); + }; + const frame = requestAnimationFrame(() => setTimeout(mount, 0)); + const timer = setTimeout(mount, 100); + return () => { + cancelAnimationFrame(frame); + clearTimeout(timer); + if (!mounted) release(); + }; + }, [ready]); + useEffect(() => { + const release = pendingReleaseRef.current; + if (!ready || release === null) return; + const cancel = releaseBootHoldAfterPaint(release); + pendingReleaseRef.current = null; + return cancel; + }, [ready]); + return ready; +} + /** * The single chat surface behind both `/draft/$draftId` and * `/$environmentId/$threadId`. Each draft gets its own ChatView instance (so @@ -101,6 +145,7 @@ export const ThreadRouteView = memo(function ThreadRouteView({ const draftSession = useComposerDraftStore((store) => draftId === null ? null : store.getDraftSession(draftId), ); + const chatViewReady = useBootDeferredChatView(); const threadRefs = useThreadRefs(); // The server thread this view is about: the route's own ref, or the draft's // reserved ref once the server knows it. @@ -334,36 +379,37 @@ export const ThreadRouteView = memo(function ThreadRouteView({ ref={insetRef} className="h-svh min-h-0 overflow-hidden overscroll-y-none md:h-dvh" > - {nextKeptViews.map((view) => ( - - ( + - - {view.draftId === null ? ( - - ) : ( - - )} - - - - ))} + + + {view.draftId === null ? ( + + ) : ( + + )} + + +
+ ))} ); }); diff --git a/apps/web/src/connection/catalog.ts b/apps/web/src/connection/catalog.ts index 971fa891106fc..8733912600c88 100644 --- a/apps/web/src/connection/catalog.ts +++ b/apps/web/src/connection/catalog.ts @@ -1,5 +1,259 @@ -import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections"; +import { + AVAILABLE_CONNECTION_STATE, + BearerConnectionTarget, + type ConnectionCatalogEntry, + type ConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, + type SupervisorConnectionState, +} from "@t3tools/client-runtime/connection"; +import { + createEnvironmentCatalogAtoms, + type EnvironmentCatalogState, +} from "@t3tools/client-runtime/state/connections"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { type BootCatalogEntry, readBootSnapshot, recordBootCatalog } from "../bootSnapshot"; +import { readPrimaryEnvironmentTarget } from "../environments/primary/target"; +import { isHostedStaticApp } from "../hostedPairing"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { connectionAtomRuntime } from "./runtime"; -export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime); +const liveCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime); + +function bootTarget(entry: BootCatalogEntry): ConnectionTarget | null { + const base = { environmentId: entry.environmentId, label: entry.label }; + switch (entry.kind) { + case "primary": { + let target: { readonly httpBaseUrl: string; readonly wsBaseUrl: string } | null = null; + try { + target = readPrimaryEnvironmentTarget()?.target ?? null; + } catch { + // A desktop backend that has not started yet keeps its last address. + } + const httpBaseUrl = target?.httpBaseUrl ?? entry.httpBaseUrl; + const wsBaseUrl = target?.wsBaseUrl ?? entry.wsBaseUrl; + return httpBaseUrl === undefined || wsBaseUrl === undefined + ? null + : new PrimaryConnectionTarget({ ...base, httpBaseUrl, wsBaseUrl }); + } + case "bearer": + return entry.connectionId === undefined + ? null + : new BearerConnectionTarget({ ...base, connectionId: entry.connectionId }); + case "ssh": + return entry.connectionId === undefined + ? null + : new SshConnectionTarget({ ...base, connectionId: entry.connectionId }); + case "relay": + return new RelayConnectionTarget(base); + } +} + +let bootCatalogState: EnvironmentCatalogState | null | undefined; + +/** The catalog this page booted with, as display-only entries. */ +export function readBootCatalogState(): EnvironmentCatalogState | null { + if (bootCatalogState !== undefined) return bootCatalogState; + bootCatalogState = null; + const snapshot = readBootSnapshot(); + if (snapshot === null || snapshot.catalog.length === 0) return null; + const requiresPrimary = !isHostedStaticApp() && !isLocalEnvironmentDisabled(); + const entries = new Map(); + try { + for (const entry of snapshot.catalog) { + if (entry.kind === "primary" && !requiresPrimary) continue; + const target = bootTarget(entry); + if (target === null) continue; + entries.set(entry.environmentId, { + target, + profile: Option.none(), + enabled: entry.enabled, + }); + } + } catch { + return null; + } + if (entries.size === 0) return null; + bootCatalogState = { isReady: true, entries }; + return bootCatalogState; +} + +function hasPrimaryEntry(catalog: EnvironmentCatalogState): boolean { + for (const entry of catalog.entries.values()) { + if (entry.target._tag === "PrimaryConnectionTarget") return true; + } + return false; +} + +/** + * True from a boot with a snapshot until its first screen is painted. While + * held, the boot-aware atoms serve only snapshot data and never touch the + * live ones, so the first frames render before connecting, IndexedDB and + * server decoding compete with them for the main thread. + */ +export const bootHoldAtom = Atom.make(readBootSnapshot() !== null).pipe( + Atom.keepAlive, + Atom.withLabel("web-boot-hold"), +); +const bootHoldListeners: Array<() => void> = []; +let bootHoldRetainers = 0; + +export function isBootHeld(): boolean { + return appAtomRegistry.get(bootHoldAtom); +} + +/** Runs `start` once the boot hold is released, or now when there is none. */ +export function afterBootHold(start: () => void): void { + if (isBootHeld()) bootHoldListeners.push(start); + else start(); +} + +function releaseBootHold(): void { + if (!isBootHeld()) return; + appAtomRegistry.set(bootHoldAtom, false); + for (const start of bootHoldListeners.splice(0)) start(); +} + +/** + * Keeps the boot hold until the returned function runs. The hold ends when + * every part of the first screen that retained it has painted. + */ +export function retainBootHold(): () => void { + if (!isBootHeld()) return () => {}; + bootHoldRetainers++; + let released = false; + return () => { + if (released) return; + released = true; + bootHoldRetainers--; + if (bootHoldRetainers === 0) releaseBootHold(); + }; +} + +/** + * Runs `release` after the next frame paints. Hidden windows get no animation + * frames, hence the timer. + */ +export function releaseBootHoldAfterPaint(release: () => void): () => void { + const frame = requestAnimationFrame(() => setTimeout(release, 0)); + const timer = setTimeout(release, 250); + return () => { + cancelAnimationFrame(frame); + clearTimeout(timer); + release(); + }; +} + +if (typeof window !== "undefined" && readBootSnapshot() !== null) { + // A boot that fails before its first screen still connects. + setTimeout(releaseBootHold, 10_000); +} + +let lastMerged: { + readonly live: EnvironmentCatalogState; + readonly merged: EnvironmentCatalogState; +} | null = null; + +/** + * Shows the boot catalog until the live one can stand on its own: the + * registry has loaded the saved environments and, where there is one, + * platform discovery has registered the primary environment. + */ +const catalogValueAtom = Atom.make((get): EnvironmentCatalogState => { + const boot = readBootCatalogState(); + if (boot !== null && get(bootHoldAtom)) return boot; + const live = get(liveCatalog.catalogValueAtom); + if (boot === null) return live; + if (!live.isReady) return boot; + if (hasPrimaryEntry(live)) return live; + let bootPrimary: [EnvironmentId, ConnectionCatalogEntry] | null = null; + for (const entry of boot.entries) { + if (entry[1].target._tag === "PrimaryConnectionTarget") bootPrimary = entry; + } + if (bootPrimary === null || live.entries.has(bootPrimary[0])) return live; + if (lastMerged?.live === live) return lastMerged.merged; + const merged: EnvironmentCatalogState = { + isReady: true, + entries: new Map([bootPrimary, ...live.entries]), + }; + lastMerged = { live, merged }; + return merged; +}).pipe(Atom.withLabel("web-environment-catalog-value")); + +const CONNECTING_STATE: SupervisorConnectionState = Object.freeze({ + ...AVAILABLE_CONNECTION_STATE, + desired: true, + phase: "connecting", + stage: "preparing", +}); +const CONNECTING_RESULT = AsyncResult.success(CONNECTING_STATE); + +/** + * A boot-only environment has no supervisor yet. It reports connecting, which + * is what startup is doing, instead of an idle "available" state. + */ +const stateAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const bootEntry = readBootCatalogState()?.entries.has(environmentId) === true; + if (bootEntry && get(bootHoldAtom)) return CONNECTING_RESULT; + const live = get(liveCatalog.stateAtom(environmentId)); + if (!bootEntry || get(liveCatalog.catalogValueAtom).entries.has(environmentId)) return live; + return CONNECTING_RESULT; + }).pipe(Atom.withLabel(`web-environment-connection-state:${environmentId}`)), +); + +export const environmentCatalog = { + ...liveCatalog, + catalogValueAtom, + stateAtom, +}; + +function bootCatalogEntry( + environmentId: EnvironmentId, + entry: ConnectionCatalogEntry, +): BootCatalogEntry { + const base = { environmentId, label: entry.target.label, enabled: entry.enabled }; + const target = entry.target; + switch (target._tag) { + case "PrimaryConnectionTarget": + return { + ...base, + kind: "primary", + httpBaseUrl: target.httpBaseUrl, + wsBaseUrl: target.wsBaseUrl, + }; + case "BearerConnectionTarget": + return { ...base, kind: "bearer", connectionId: target.connectionId }; + case "SshConnectionTarget": + return { ...base, kind: "ssh", connectionId: target.connectionId }; + case "RelayConnectionTarget": + return { ...base, kind: "relay" }; + } +} + +let bootCatalogRecorderStarted = false; + +/** Keeps the boot snapshot's environment list in step with the live catalog. */ +export function startBootCatalogRecorder(): void { + if (bootCatalogRecorderStarted) return; + bootCatalogRecorderStarted = true; + const requiresPrimary = !isHostedStaticApp() && !isLocalEnvironmentDisabled(); + appAtomRegistry.subscribe( + liveCatalog.catalogValueAtom, + (catalog) => { + // Before discovery registers the primary environment the list is incomplete. + if (!catalog.isReady || (requiresPrimary && !hasPrimaryEntry(catalog))) return; + recordBootCatalog( + Array.from(catalog.entries, ([environmentId, entry]) => + bootCatalogEntry(environmentId, entry), + ), + ); + }, + { immediate: true }, + ); +} diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 15fca39e174e7..206696e86d543 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -37,6 +37,12 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { projectFaviconCache } from "../assets/projectFaviconCache"; +import { + forgetBootEnvironment, + readBootSnapshot, + recordBootServerConfig, + recordBootShell, +} from "../bootSnapshot"; const DATABASE_NAME = "t3code:connection-runtime"; const DATABASE_VERSION = 4; @@ -365,6 +371,25 @@ function removeDatabaseValuesInRange( ); } +const bootShellsTaken = new Set(); +const bootServerConfigsTaken = new Set(); + +/** + * Hands each environment's boot snapshot value to the first cache load only. + * A later load (a reconnected environment) reads IndexedDB, which the live + * state has written since. + */ +function takeBootValue( + taken: Set, + environmentId: EnvironmentId, + read: (snapshot: NonNullable>) => A | undefined, +): A | undefined { + if (taken.has(environmentId)) return undefined; + taken.add(environmentId); + const snapshot = readBootSnapshot(); + return snapshot === null ? undefined : read(snapshot); +} + function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { return `${environmentId}:${threadId}`; } @@ -709,8 +734,17 @@ export const layer = Layer.effectContext( })), }); const cacheStore = Persistence.EnvironmentCacheStore.of({ - loadShell: (environmentId) => - readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( + loadShell: (environmentId) => { + // The boot snapshot already holds this page's copy, decoded. + const boot = takeBootValue(bootShellsTaken, environmentId, (snapshot) => + snapshot.shells.get(environmentId), + ); + if (boot !== undefined) { + return Effect.promise(() => projectFaviconCache.hydrate()).pipe( + Effect.as(Option.some(boot)), + ); + } + return readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())), Effect.flatMap((raw) => { if (typeof raw !== "string") { @@ -729,7 +763,8 @@ export const layer = Layer.effectContext( ); }), Effect.mapError((cause) => persistenceError("load-shell", cause)), - ), + ); + }, saveShell: (environmentId, snapshot) => Effect.gen(function* () { const encoded = yield* encodeStoredShellSnapshot({ @@ -738,6 +773,7 @@ export const layer = Layer.effectContext( snapshot, }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); + recordBootShell(environmentId, snapshot); }).pipe( Effect.mapError((cause) => cause._tag === "ConnectionPersistenceError" @@ -745,8 +781,12 @@ export const layer = Layer.effectContext( : persistenceError("save-shell", cause), ), ), - loadServerConfig: (environmentId) => - readDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId).pipe( + loadServerConfig: (environmentId) => { + const boot = takeBootValue(bootServerConfigsTaken, environmentId, (snapshot) => + snapshot.serverConfigs.get(environmentId), + ); + if (boot !== undefined) return Effect.succeedSome(boot); + return readDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId).pipe( Effect.flatMap((raw) => { if (typeof raw !== "string") { return Effect.succeedNone; @@ -763,7 +803,8 @@ export const layer = Layer.effectContext( ? cause : persistenceError("load-server-config", cause), ), - ), + ); + }, saveServerConfig: (environmentId, config) => Effect.gen(function* () { const encoded = yield* encodeStoredServerConfig({ @@ -772,6 +813,7 @@ export const layer = Layer.effectContext( config, }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause))); yield* writeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId, encoded); + recordBootServerConfig(environmentId, config); }).pipe( Effect.mapError((cause) => cause._tag === "ConnectionPersistenceError" @@ -891,6 +933,7 @@ export const layer = Layer.effectContext( clear: (environmentId) => Effect.all( [ + Effect.sync(() => forgetBootEnvironment(environmentId)), Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)), removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), removeDatabaseValuesInRange( diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 46d946e9f7cb0..950ae732b6029 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,6 +5,7 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "./index.css"; import { prepareProviderAuthDelivery } from "./providerAuthDelivery"; +import { ensureClientSettingsHydrated } from "./hooks/useSettings"; import { isElectron } from "./env"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter, preloadInitialRouteChunks } from "./router"; @@ -19,6 +20,9 @@ import { restoreStackTraces } from "./lib/bootStackTraces"; // Every import above has evaluated; startup code runs with full stacks. restoreStackTraces(); prepareProviderAuthDelivery(); +// The first-run gate renders nothing until saved settings load; start now so +// the first commit already has them. +void ensureClientSettingsHydrated().catch(() => undefined); // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 14cc31ae5e3b0..402cf81361ec5 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -1,6 +1,7 @@ import { DEFAULT_SERVER_SETTINGS, type EditorId, + type EnvironmentId, type EnvironmentTheme, type ServerConfig, type ServerConfigStreamEvent, @@ -16,7 +17,8 @@ import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; -import { environmentCatalog } from "../connection/catalog"; +import { readBootSnapshot } from "../bootSnapshot"; +import { bootHoldAtom, environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { environmentSession } from "./session"; @@ -27,12 +29,28 @@ import { environmentSession } from "./session"; // gating on "primary right now" would leave a newly promoted environment with // no themes until it reconnected. The set is capped server-side and stripped // before caching, so following all of them costs a few KB per environment. -export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { +const liveServerEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, environmentThemes: true, usageLimitSources: true, usageLimitsCommand: true, }); +const emptyConfigValueAtom = liveServerEnvironment.configValueAtom(null); +/** + * Until the environment's own configuration loads (IndexedDB cache or the + * server), the boot snapshot's copy stands in, so provider and model labels + * paint with the first frame. + */ +const configValueAtom = Atom.family((environmentId: EnvironmentId | null) => + environmentId === null + ? emptyConfigValueAtom + : Atom.make((get): ServerConfig | null => { + const boot = readBootSnapshot()?.serverConfigs.get(environmentId) ?? null; + if (boot !== null && get(bootHoldAtom)) return boot; + return get(liveServerEnvironment.configValueAtom(environmentId)) ?? boot; + }).pipe(Atom.withLabel(`web-environment-server-config:${environmentId}`)), +); +export const serverEnvironment = { ...liveServerEnvironment, configValueAtom }; /** Updates a host whose protocol is too old for this client to connect to. */ export const updateOutdatedServer = createOutdatedServerUpdateCommand(connectionAtomRuntime); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ @@ -60,6 +78,13 @@ const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { return EMPTY_PRIMARY_SERVER_STATE; } + if (get(bootHoldAtom)) { + return { + config: get(serverEnvironment.configValueAtom(environmentId)), + latestEvent: null, + welcome: null, + }; + } const target = { environmentId, input: {} }; const configProjection = Option.getOrNull( AsyncResult.value(get(serverEnvironment.configProjection(target))), diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index fa78f9bff1d98..dbdbb71bfac6e 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -12,11 +12,18 @@ import { type EnvironmentCatalogState, enabledEnvironmentIds, } from "@t3tools/client-runtime/state/connections"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, OrchestrationV2ShellSnapshot } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; -import { environmentCatalog } from "../connection/catalog"; +import { readBootSnapshot } from "../bootSnapshot"; +import { + afterBootHold, + bootHoldAtom, + environmentCatalog, + readBootCatalogState, + startBootCatalogRecorder, +} from "../connection/catalog"; import { primaryEnvironmentMissingAtom } from "../connection/platform"; import { connectionAtomRuntime } from "../connection/runtime"; import { isHostedStaticApp } from "../hostedPairing"; @@ -24,12 +31,104 @@ import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { appAtomRegistry } from "../rpc/atomRegistry"; export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); -export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); +const liveEnvironmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); + +const bootShellStates = new Map(); + +function readBootShell(environmentId: EnvironmentId): OrchestrationV2ShellSnapshot | null { + let snapshot = bootShellStates.get(environmentId); + if (snapshot === undefined) { + snapshot = readBootSnapshot()?.shells.get(environmentId) ?? null; + bootShellStates.set(environmentId, snapshot); + } + return snapshot; +} + +const bootOverlays = new Map< + EnvironmentId, + WeakMap +>(); + +/** + * Until the live state holds a snapshot (from IndexedDB or the server), the + * boot snapshot stands in as cached data, keeping the live status and error. + */ +function withBootShell( + environmentId: EnvironmentId, + live: EnvironmentShellState, +): EnvironmentShellState { + if (Option.isSome(live.snapshot) || live.status === "live") return live; + const boot = readBootShell(environmentId); + if (boot === null) return live; + let overlays = bootOverlays.get(environmentId); + if (overlays === undefined) { + overlays = new WeakMap(); + bootOverlays.set(environmentId, overlays); + } + let overlay = overlays.get(live); + if (overlay === undefined) { + overlay = { + snapshot: Option.some(boot), + status: live.status === "empty" ? "cached" : live.status, + error: live.error, + }; + overlays.set(live, overlay); + } + return overlay; +} + +const EMPTY_LIVE_SHELL_STATE: EnvironmentShellState = { + snapshot: Option.none(), + status: "empty", + error: Option.none(), +}; +const bootStateResults = new WeakMap< + EnvironmentShellState, + AsyncResult.AsyncResult +>(); + +function bootStateResult(state: EnvironmentShellState) { + let result = bootStateResults.get(state); + if (result === undefined) { + result = AsyncResult.success(state); + bootStateResults.set(state, result); + } + return result; +} + +/** Shell state with the boot snapshot standing in until live data arrives. */ +export const environmentShell = { + stateAtom: Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + if (get(bootHoldAtom)) { + const held = withBootShell(environmentId, EMPTY_LIVE_SHELL_STATE); + if (held !== EMPTY_LIVE_SHELL_STATE) return bootStateResult(held); + } + const live = get(liveEnvironmentShell.stateAtom(environmentId)); + if (AsyncResult.isFailure(live)) return live; + const liveState = Option.getOrElse(AsyncResult.value(live), () => EMPTY_LIVE_SHELL_STATE); + const shown = withBootShell(environmentId, liveState); + return shown === liveState ? live : bootStateResult(shown); + }).pipe(Atom.withLabel(`web-environment-shell-state:${environmentId}`)), + ), + stateValueAtom: Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + if (get(bootHoldAtom)) { + const held = withBootShell(environmentId, EMPTY_LIVE_SHELL_STATE); + if (held !== EMPTY_LIVE_SHELL_STATE) return held; + } + return withBootShell(environmentId, get(liveEnvironmentShell.stateValueAtom(environmentId))); + }).pipe(Atom.withLabel(`web-environment-shell-state-value:${environmentId}`)), + ), +}; export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { - const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); - if (Option.isNone(catalog)) { + const catalog = get(environmentCatalog.catalogValueAtom); + if ( + catalog !== readBootCatalogState() && + Option.isNone(AsyncResult.value(get(environmentCatalog.catalogAtom))) + ) { return false; } // The persisted catalog emits before platform discovery registers the @@ -39,13 +138,13 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { !isHostedStaticApp() && !isLocalEnvironmentDisabled() && !get(primaryEnvironmentMissingAtom) && - !Array.from(catalog.value.entries.values()).some( + !Array.from(catalog.entries.values()).some( (entry) => entry.target._tag === "PrimaryConnectionTarget", ) ) { return false; } - for (const environmentId of enabledEnvironmentIds(catalog.value)) { + for (const environmentId of enabledEnvironmentIds(catalog)) { if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) { continue; } @@ -76,7 +175,11 @@ let environmentShellsStarted = false; export function startEnvironmentShells(): void { if (environmentShellsStarted) return; environmentShellsStarted = true; - appAtomRegistry.mount(allEnvironmentShellsBootstrappedAtom); + // A boot painting from its snapshot connects after the first frame. + afterBootHold(() => { + appAtomRegistry.mount(allEnvironmentShellsBootstrappedAtom); + startBootCatalogRecorder(); + }); } /** Cached or missing snapshots cannot establish that a saved project no longer exists. */ From 35cfcf6a90282adffd16b1e7b5bd6ab910b74f6b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 02:15:47 +0000 Subject: [PATCH 034/241] perf(web): render before the session check when a snapshot exists A browser that booted with a snapshot of this server's workspace no longer waits for /api/auth/session before rendering. The check keeps running; a rejected session drops the snapshot and boots again into pairing. --- apps/web/src/routes/__root.tsx | 45 ++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index cfc210632e020..db44cc44d6911 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -66,7 +66,11 @@ import { import { useUiStateStore } from "../uiStateStore"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { configureClientTracing } from "../observability/clientTracing"; -import { resolveInitialServerAuthGateState } from "../environments/primary"; +import { + peekPairingTokenFromUrl, + resolveInitialServerAuthGateState, +} from "../environments/primary"; +import { discardBootSnapshot, readBootSnapshot } from "../bootSnapshot"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { useAtomValue } from "@effect/atom-react"; @@ -131,7 +135,9 @@ export const Route = createRootRoute({ const authGate = resolveInitialServerAuthGateState(); prefetchPrimaryEnvironmentDescriptor(); - const authGateState = await authGate; + const authGateState = shouldRenderBeforeAuthGate(location.pathname) + ? renderBeforeAuthGate(authGate) + : await authGate; // Connect and read cached shells while the route chunks still load. if (authGateState.status === "authenticated") startEnvironmentShells(); if ( @@ -154,6 +160,41 @@ export const Route = createRootRoute({ }), }); +type InitialAuthGateState = Awaited>; + +// "pending" while a render that skipped the session check waits for it. +let earlyAuthGate: "unused" | "pending" | "settled" = "unused"; + +/** + * A browser that booted with a snapshot of this server's workspace paints it + * while the session check runs, and navigations do not wait for it either. + * Pairing links and first visits wait for the check. + */ +function shouldRenderBeforeAuthGate(pathname: string): boolean { + if (pathname === "/pair" || peekPairingTokenFromUrl() !== null) return false; + if (earlyAuthGate !== "unused") return earlyAuthGate === "pending"; + return readBootSnapshot()?.catalog.some((entry) => entry.kind === "primary") === true; +} + +function renderBeforeAuthGate(authGate: Promise): InitialAuthGateState { + if (earlyAuthGate === "unused") { + earlyAuthGate = "pending"; + // A rejected session drops the snapshot and boots again into pairing. A + // check that keeps failing leaves the connection's own status on screen. + void authGate.then((state) => { + if (state.status === "authenticated") { + earlyAuthGate = "settled"; + return; + } + discardBootSnapshot(); + window.location.reload(); + }, ignoreAuthGateFailure); + } + return { status: "authenticated" }; +} + +function ignoreAuthGateFailure() {} + function RootRouteNotFoundView() { return (
From 40b3f5bc448f7e8c28cc8d3426648a82c0f286c0 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 02:15:47 +0000 Subject: [PATCH 035/241] perf(web): open the landing draft before the server answers The index route waited for the websocket, the server config and t3.json before opening the most recent project's draft. With a boot snapshot it now opens that draft (or mints one from the cached settings) at once, so the composer accepts typing immediately. The server's defaults land on it when the session is ready, keeping a workspace choice the user made meanwhile, and sending waits until then with a Connecting label. --- apps/web/src/bootLanding.ts | 84 ++++++++++++++++++++ apps/web/src/bootLandingDraft.ts | 99 ++++++++++++++++++++++++ apps/web/src/components/ChatView.tsx | 35 +++++---- apps/web/src/hooks/useHandleNewThread.ts | 51 ++++++++++-- apps/web/src/routes/_chat.index.tsx | 15 +++- apps/web/src/routes/_chat.tsx | 36 ++++++++- apps/web/src/state/session.ts | 10 +++ 7 files changed, 305 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/bootLanding.ts create mode 100644 apps/web/src/bootLandingDraft.ts diff --git a/apps/web/src/bootLanding.ts b/apps/web/src/bootLanding.ts new file mode 100644 index 0000000000000..eca0711defd22 --- /dev/null +++ b/apps/web/src/bootLanding.ts @@ -0,0 +1,84 @@ +import type { ScopedProjectRef } from "@t3tools/contracts"; +import { useSyncExternalStore } from "react"; + +import type { DraftId, DraftSessionState } from "./composerDraftStore"; + +/** + * The index route normally opens the most recent project's draft only after + * the server confirms its defaults (server settings, t3.json). A browser that + * booted with a snapshot opens the empty draft it already has at once, and the + * defaults land on it when the server answers. Until then the draft accepts + * typing but not sending. + */ +export interface PendingLandingDraft { + readonly draftId: DraftId; + readonly projectRef: ScopedProjectRef; + /** The draft's workspace context when it opened; a user change keeps theirs. */ + readonly openedWith: LandingDraftContext; +} + +export type LandingDraftContext = Pick< + DraftSessionState, + "branch" | "worktreePath" | "envMode" | "startFromOrigin" | "runtimeMode" | "interactionMode" +>; + +export function landingDraftContext(draft: DraftSessionState): LandingDraftContext { + return { + branch: draft.branch, + worktreePath: draft.worktreePath, + envMode: draft.envMode, + startFromOrigin: draft.startFromOrigin, + runtimeMode: draft.runtimeMode, + interactionMode: draft.interactionMode, + }; +} + +export function sameLandingDraftContext(a: LandingDraftContext, b: LandingDraftContext): boolean { + return ( + a.branch === b.branch && + a.worktreePath === b.worktreePath && + a.envMode === b.envMode && + a.startFromOrigin === b.startFromOrigin && + a.runtimeMode === b.runtimeMode && + a.interactionMode === b.interactionMode + ); +} + +let pending: PendingLandingDraft | null = null; +const listeners = new Set<() => void>(); + +function emit() { + for (const listener of listeners) listener(); +} + +export function readPendingLandingDraft(): PendingLandingDraft | null { + return pending; +} + +export function setPendingLandingDraft(next: PendingLandingDraft): void { + pending = next; + emit(); +} + +export function clearPendingLandingDraft(draftId: DraftId): void { + if (pending?.draftId !== draftId) return; + pending = null; + emit(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function usePendingLandingDraft(): PendingLandingDraft | null { + return useSyncExternalStore(subscribe, readPendingLandingDraft, readPendingLandingDraft); +} + +/** Whether this draft still waits for the server's defaults before it can send. */ +export function useLandingDraftDefaultsPending(draftId: DraftId | null): boolean { + const landing = usePendingLandingDraft(); + return draftId !== null && landing?.draftId === draftId; +} diff --git a/apps/web/src/bootLandingDraft.ts b/apps/web/src/bootLandingDraft.ts new file mode 100644 index 0000000000000..3a00bea9799f7 --- /dev/null +++ b/apps/web/src/bootLandingDraft.ts @@ -0,0 +1,99 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; + +import { + landingDraftContext, + readPendingLandingDraft, + setPendingLandingDraft, +} from "./bootLanding"; +import { readBootSnapshot } from "./bootSnapshot"; +import { sortScopedProjectsForSidebar } from "./components/Sidebar.logic"; +import { + composerDraftHasUserContent, + type DraftId, + markPromotedDraftThreadByRef, + useComposerDraftStore, +} from "./composerDraftStore"; +import { resolveNewDraftStartFromOrigin } from "./lib/chatThreadActions"; +import { newDraftId, newThreadId } from "./lib/utils"; +import { ensureClientSettingsHydrated, getClientSettings } from "./hooks/useSettings"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "./logicalProject"; +import { appAtomRegistry } from "./rpc/atomRegistry"; +import { readProjects, readThreadShell } from "./state/entities"; +import { allEnvironmentShellsBootstrappedAtom } from "./state/shell"; +import { environmentThreadShells } from "./state/threads"; + +/** + * The draft the index route can open right away, or null when it must wait + * for the server: no boot snapshot or no project yet. Like the new-thread + * handler, it reuses the most recent project's empty draft or mints one; a + * minted draft starts from the cached settings until the real defaults land. + */ +export async function resolveLandingDraft(): Promise { + const pending = readPendingLandingDraft(); + if (pending !== null) return pending.draftId; + if (readBootSnapshot() === null || !appAtomRegistry.get(allEnvironmentShellsBootstrappedAtom)) { + return null; + } + const project = + sortScopedProjectsForSidebar( + readProjects(), + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + "updated_at", + )[0] ?? null; + if (project === null) return null; + // The draft is keyed by the user's project grouping. + await ensureClientSettingsHydrated().catch(() => undefined); + const logicalProjectKey = deriveLogicalProjectKeyFromSettings( + project, + selectProjectGroupingSettings(getClientSettings()), + ); + const projectRef = scopeProjectRef(project.environmentId, project.id); + const store = useComposerDraftStore.getState(); + const stored = store.getDraftSessionByLogicalProjectKey(logicalProjectKey); + const storedRef = stored ? scopeThreadRef(stored.environmentId, stored.threadId) : null; + const storedPromoted = storedRef !== null && readThreadShell(storedRef) !== null; + if (storedRef !== null && stored?.promotedTo == null && storedPromoted) { + markPromotedDraftThreadByRef(storedRef); + } + let draftId: DraftId; + if ( + stored !== null && + stored.promotedTo == null && + !storedPromoted && + !composerDraftHasUserContent(store.getComposerDraft(stored.draftId)) + ) { + draftId = stored.draftId; + } else { + // A draft with typed text stays where it is; a new one opens beside it. + const settings = resolveProjectSettings( + readBootSnapshot()?.serverConfigs.get(project.environmentId)?.settings ?? + DEFAULT_SERVER_SETTINGS, + project.id, + project, + null, + ).settings; + draftId = newDraftId(); + store.setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { + threadId: newThreadId(), + createdAt: new Date().toISOString(), + branch: null, + worktreePath: null, + envMode: settings.defaultThreadEnvMode, + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: settings.defaultThreadEnvMode, + newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, + }), + runtimeMode: settings.defaultRuntimeMode, + }); + store.applyStickyState(draftId); + } + const draft = useComposerDraftStore.getState().getDraftSession(draftId); + if (draft === null) return null; + setPendingLandingDraft({ draftId, projectRef, openedWith: landingDraftContext(draft) }); + return draftId; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b59e3b03878ae..ff15cc7a79e98 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -378,6 +378,7 @@ import { useComposerDraftStore, DraftId, } from "../composerDraftStore"; +import { useLandingDraftDefaultsPending } from "../bootLanding"; import { formatTerminalContextLabel, type TerminalContextDraft, @@ -403,7 +404,7 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; -import { useEnvironmentScope } from "~/state/session"; +import { useEnvironmentScope, useEnvironmentSessionPending } from "~/state/session"; import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, @@ -1642,6 +1643,8 @@ export default function ChatView(props: ChatViewProps) { forceExpandedMobileComposer = false, } = props; const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); + // Before the session answers, missing grants mean "not connected yet". + const sessionPending = useEnvironmentSessionPending(environmentId); const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); const hasTerminalWriteAccess = useCallback( () => readEnvironmentScope(environmentId, AuthTerminalOperateScope), @@ -1649,6 +1652,8 @@ export default function ChatView(props: ChatViewProps) { ); const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope); const draftId = routeKind === "draft" ? props.draftId : null; + // A draft opened before the server answered sends once its defaults land. + const landingDefaultsPending = useLandingDraftDefaultsPending(draftId); const handleNewThread = useNewThreadHandler(); const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const routeThreadRef = useMemo( @@ -11781,19 +11786,21 @@ export default function ChatView(props: ChatViewProps) { canResume={resumableRunId !== null || hasHeldQueuedRuns} isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ - !canOperateThread - ? "This connection cannot change threads." - : isEnvironmentChanging - ? "Preparing machine" - : isRevertingCheckpoint - ? "Rewinding conversation" - : feedbackUploading - ? "Sending feedback" - : threadDetailLoading - ? "Messages loading" - : worktreeSetupBlocksSend - ? "Preparing worktree" - : projectCloneSendBlockReason + landingDefaultsPending || (!canOperateThread && sessionPending) + ? "Connecting" + : !canOperateThread + ? "This connection cannot change threads." + : isEnvironmentChanging + ? "Preparing machine" + : isRevertingCheckpoint + ? "Rewinding conversation" + : feedbackUploading + ? "Sending feedback" + : threadDetailLoading + ? "Messages loading" + : worktreeSetupBlocksSend + ? "Preparing worktree" + : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree} queuedRunsControl={ diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 17597e213d653..6a14f04cf59b3 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -34,6 +34,11 @@ import { environmentServerConfigsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; +import { + landingDraftContext, + type PendingLandingDraft, + sameLandingDraftContext, +} from "../bootLanding"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -72,6 +77,12 @@ export function useNewThreadHandler() { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; + /** + * Applies the defaults to a draft the index route opened before the + * server answered (see bootLanding.ts): as if opened from "/", typed + * text stays, and a workspace context the user changed meanwhile wins. + */ + landing?: PendingLandingDraft; }, // Which draft the thread ended up in, so a caller that has something to put in it — a // prepared checkout, a task to write — addresses that one rather than looking the project @@ -92,7 +103,9 @@ export function useNewThreadHandler() { } = useComposerDraftStore.getState(); const requestingRouteHref = router.state.location.href; const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; - const currentRouteTarget = getCurrentRouteTarget(); + const landing = options?.landing ?? null; + // A landing draft was opened from "/", so nothing carries over from it. + const currentRouteTarget = landing ? null : getCurrentRouteTarget(); // A new thread carries the user's working mode from the thread being // viewed. The target project's configured model still wins; interaction // mode carries independently. Permissions, branch, worktree, and env mode @@ -190,9 +203,15 @@ export function useNewThreadHandler() { // drafts rather than deleting them. const emptyStoredDraftThread = reusableStoredDraftThread && - !composerDraftHasUserContent(getComposerDraft(reusableStoredDraftThread.draftId)) + (reusableStoredDraftThread.draftId === landing?.draftId || + !composerDraftHasUserContent(getComposerDraft(reusableStoredDraftThread.draftId))) ? reusableStoredDraftThread : null; + // The landing draft stays on screen; one that stopped being this + // project's reusable draft keeps whatever it has. + if (landing !== null && emptyStoredDraftThread?.draftId !== landing.draftId) { + return Promise.resolve(null); + } const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget ? currentRouteTarget.kind === "server" ? getDraftThread(currentRouteTarget.threadRef) @@ -203,6 +222,7 @@ export function useNewThreadHandler() { const isDraftAlreadyOpen = currentRouteTarget?.kind === "draft" && currentRouteTarget.draftId === emptyStoredDraftThread.draftId; + const isLandingDraft = landing?.draftId === emptyStoredDraftThread.draftId; const hasExplicitWorkspaceOption = hasBranchOption || hasWorktreePathOption || @@ -222,7 +242,7 @@ export function useNewThreadHandler() { workspaceContext = pickExplicitWorkspaceOptions(options); } else if (!isDraftAlreadyOpen) { const defaultEnvMode = await resolveDefaultEnvMode(); - if (routeChangedSinceRequest()) { + if (!isLandingDraft && routeChangedSinceRequest()) { return null; } // The await yields. If the draft was opened (a concurrent @@ -246,9 +266,27 @@ export function useNewThreadHandler() { const investedMeanwhile = composerDraftHasUserContent( getComposerDraft(emptyStoredDraftThread.draftId), ); - if (openedMeanwhile || promotedMeanwhile || remappedMeanwhile || investedMeanwhile) { + if ( + promotedMeanwhile || + remappedMeanwhile || + (!isLandingDraft && (openedMeanwhile || investedMeanwhile)) + ) { return null; } + const landingSession = isLandingDraft + ? getDraftSession(emptyStoredDraftThread.draftId) + : null; + if ( + landing !== null && + landingSession !== null && + !sameLandingDraftContext(landing.openedWith, landingDraftContext(landingSession)) + ) { + // The user picked a workspace while the defaults loaded. + return { + draftId: emptyStoredDraftThread.draftId, + threadId: emptyStoredDraftThread.threadId, + }; + } workspaceContext = { branch: null, worktreePath: null, @@ -312,8 +350,9 @@ export function useNewThreadHandler() { // again would push a duplicate history entry. const routeTargetAfterWrites = getCurrentRouteTarget(); if ( - routeTargetAfterWrites?.kind === "draft" && - routeTargetAfterWrites.draftId === emptyStoredDraftThread.draftId + isLandingDraft || + (routeTargetAfterWrites?.kind === "draft" && + routeTargetAfterWrites.draftId === emptyStoredDraftThread.draftId) ) { return opened; } diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 61e9e7a2e3a28..37378b2984c64 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,9 +1,10 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, redirect } from "@tanstack/react-router"; import { LinkIcon, PlusIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { resolveLandingDraft } from "../bootLandingDraft"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { isElectron } from "../env"; import { NoProjectsHero } from "../components/NoProjectsHero"; @@ -110,7 +111,19 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) { ); } +let landingConsidered = false; + export const Route = createFileRoute("/_chat/")({ + // On the first load, a browser with a boot snapshot opens its empty draft + // without waiting for the server (see bootLanding.ts). + beforeLoad: async () => { + if (landingConsidered) return; + landingConsidered = true; + const draftId = await resolveLandingDraft(); + if (draftId !== null) { + throw redirect({ to: "/draft/$draftId", params: { draftId }, replace: true }); + } + }, component: ChatIndexRouteView, }); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 20377c3734f75..141f3d57e088a 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,7 +1,8 @@ -import { AuthPreviewOperateScope } from "@t3tools/contracts"; +import { AuthPreviewOperateScope, type ServerConfig } from "@t3tools/contracts"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useLayoutEffect, useMemo } from "react"; +import { Atom } from "effect/reactivity"; +import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { registerChatSurface } from "../components/chatSurfaceSlot"; @@ -11,11 +12,12 @@ import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { isPreviewAvailableFor } from "../browser/previewRuntime"; import { usePrimaryEnvironmentId } from "../state/environments"; -import { useEnvironmentScope } from "../state/session"; +import { environmentSession, useEnvironmentScope } from "../state/session"; import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; -import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useHandleNewThread, useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { clearPendingLandingDraft, usePendingLandingDraft } from "../bootLanding"; import { useScratchProject } from "../hooks/useScratchProject"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; @@ -214,6 +216,31 @@ function ChatRouteGlobalShortcuts() { return null; } +/** Applies the server's defaults to a draft the index route opened early. */ +function LandingDraftDefaults() { + const landing = usePendingLandingDraft(); + const handleNewThread = useNewThreadHandler(); + const liveConfig = useAtomValue( + landing === null + ? NO_LIVE_CONFIG_ATOM + : environmentSession.initialConfigValueAtom(landing.projectRef.environmentId), + ); + const startedDraftIdRef = useRef(null); + useEffect(() => { + if (landing === null || liveConfig === null) return; + if (startedDraftIdRef.current === landing.draftId) return; + startedDraftIdRef.current = landing.draftId; + void handleNewThread(landing.projectRef, { replace: true, landing }) + .catch(() => undefined) + .finally(() => clearPendingLandingDraft(landing.draftId)); + }, [handleNewThread, landing, liveConfig]); + return null; +} + +const NO_LIVE_CONFIG_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-landing-no-live-config"), +); + function ChatRouteLayout() { // The thread routes render nothing here: the root layout hosts their view // (see KeptChatSurface) so it outlives the route swaps around it. @@ -221,6 +248,7 @@ function ChatRouteLayout() { return ( <> + ); diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index 883a899ef2c0b..29b7c314e15ce 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -30,6 +30,16 @@ export function useEnvironmentScope( return sessionHasScope(result, scope); } +/** True until the environment's session has answered once, so grants are unknown. */ +export function useEnvironmentSessionPending(environmentId: EnvironmentId | null): boolean { + const result = useAtomValue( + environmentId === null + ? EMPTY_SESSION_STATE_ATOM + : environmentSession.sessionStateAtom(environmentId), + ); + return result._tag !== "Failure" && Option.isNone(AsyncResult.value(result)); +} + function sessionHasScope( result: AsyncResult.AsyncResult, scope: AuthEnvironmentScope, From 41d94b1ff7c9a07dc8ca24f0d008d0cda8bb8738 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 02:21:16 +0000 Subject: [PATCH 036/241] perf(web): load cached project icons before the first commit A sidebar painted from the boot snapshot rendered before the project icon cache hydrated from IndexedDB, so icons arrived a moment later. Hydration now starts with main, alongside the client settings read. --- apps/web/src/main.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 950ae732b6029..7988ab25e2551 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,6 +6,7 @@ import "./index.css"; import { prepareProviderAuthDelivery } from "./providerAuthDelivery"; import { ensureClientSettingsHydrated } from "./hooks/useSettings"; +import { projectFaviconCache } from "./assets/projectFaviconCache"; import { isElectron } from "./env"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter, preloadInitialRouteChunks } from "./router"; @@ -23,6 +24,9 @@ prepareProviderAuthDelivery(); // The first-run gate renders nothing until saved settings load; start now so // the first commit already has them. void ensureClientSettingsHydrated().catch(() => undefined); +// Project icons read this cache synchronously, so a sidebar painted from the +// boot snapshot shows them instead of placeholders that swap later. +void projectFaviconCache.hydrate(); // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); From f0d5310263392926afa9f9f9b10665b245e5a31c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 02:32:40 +0000 Subject: [PATCH 037/241] fix(web): do not warn about reconnecting during the first connection With the composer painted from the boot snapshot, the first connection is still being made while the chat view is on screen, and on desktop the backend can take a few seconds to start. The "is reconnecting" banner now waits until the page has connected once or the first attempt fails; the send button already reports that it is connecting. --- apps/web/src/components/ChatView.logic.ts | 19 +++++++++++++++++++ apps/web/src/components/ChatView.tsx | 18 +++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 086303a58dc9f..0a631f14e9a46 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -465,6 +465,25 @@ export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () return () => globalThis.clearTimeout(timeoutId); } +const connectedEnvironmentIds = new Set(); + +/** Records that this page has reached the environment at least once. */ +export function markEnvironmentConnected(environmentId: EnvironmentId): void { + connectedEnvironmentIds.add(environmentId); +} + +/** + * A page that paints from its boot snapshot shows the composer while its + * first connection is still being made, such as a desktop backend that is + * still starting. That first attempt is startup, not a reconnect to warn about. + */ +export function isInitialEnvironmentConnect( + environmentId: EnvironmentId, + phase: string | null, +): boolean { + return phase === "connecting" && !connectedEnvironmentIds.has(environmentId); +} + export function hasEnvironmentReconnectWarningGraceElapsed( activeEnvironmentId: EnvironmentId | null, elapsedEnvironmentId: EnvironmentId | null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ff15cc7a79e98..7f3d54c4f7d68 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -521,6 +521,8 @@ import { deriveComposerSendState, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, + isInitialEnvironmentConnect, + markEnvironmentConnected, scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -2765,6 +2767,18 @@ export default function ChatView(props: ChatViewProps) { setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), ); }, [activeReconnectingEnvironmentId]); + const activeEnvironmentIdForConnection = activeEnvironment?.environmentId ?? null; + useEffect(() => { + if ( + activeEnvironmentIdForConnection !== null && + activeEnvironmentConnectionPhase === "connected" + ) { + markEnvironmentConnected(activeEnvironmentIdForConnection); + } + }, [activeEnvironmentConnectionPhase, activeEnvironmentIdForConnection]); + const activeEnvironmentInitialConnect = + activeEnvironmentIdForConnection !== null && + isInitialEnvironmentConnect(activeEnvironmentIdForConnection, activeEnvironmentConnectionPhase); const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = useMemo(() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { @@ -3201,7 +3215,8 @@ export default function ChatView(props: ChatViewProps) { // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. const suppressUnavailableBanner = - environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); + environmentReconnecting && + (updateRunning || !reconnectWarningGraceElapsed || activeEnvironmentInitialConnect); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, @@ -3321,6 +3336,7 @@ export default function ChatView(props: ChatViewProps) { setDismissedVersionMismatchKey, showVersionMismatchBanner, reconnectWarningGraceElapsed, + activeEnvironmentInitialConnect, serverUpdateFailureDismissed, serverUpdateState, versionMismatch, From 432c42dc70086f4dab6492864f5f9e0230c931a9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 03:24:42 +0000 Subject: [PATCH 038/241] perf(web): read and write the boot snapshot in proportion to its changes A JSON.parse reviver runs for every value; a walk over the parsed tree that only rebuilds DateTime and Duration markers reads a 760-thread snapshot in about 7 ms instead of 50. Writes encode each project and thread row once and reuse it while the row is unchanged. Drafts keep image attachments in localStorage, so the snapshot is written only while it leaves 500K characters free within the origin's quota (2M characters on Safari and Firefox, 4.5M on Chromium and Electron), and is removed otherwise. --- apps/web/src/bootSnapshot.ts | 106 +++++++++++++++++++++++++++++------ 1 file changed, 90 insertions(+), 16 deletions(-) diff --git a/apps/web/src/bootSnapshot.ts b/apps/web/src/bootSnapshot.ts index 6790e83b45773..334afc3875f7e 100644 --- a/apps/web/src/bootSnapshot.ts +++ b/apps/web/src/bootSnapshot.ts @@ -15,8 +15,15 @@ import * as Duration from "effect/Duration"; const STORAGE_KEY = "t3code:boot-snapshot:v1"; const FORMAT = 1; -// localStorage holds about 5M characters per origin and drafts share it. -const MAX_DOCUMENT_LENGTH = 2_500_000; +// localStorage allows 5 MB per origin. Safari and Firefox count it as UTF-16, +// about 2.5M characters; Chromium and Electron allow 10 MB and store ASCII at +// a byte per character. Drafts keep image attachments there too, so the +// snapshot only takes room the rest of the app leaves free. +const ORIGIN_CHARACTER_BUDGET = + typeof navigator !== "undefined" && /\bChrom(?:e|ium)\//.test(navigator.userAgent) + ? 4_500_000 + : 2_000_000; +const RESERVED_FOR_OTHER_KEYS = 500_000; const DATE_MARKER = "$t3date"; const DURATION_MARKER = "$t3duration"; @@ -83,7 +90,7 @@ function decodeDuration(encoded: unknown): Duration.Duration | null { } /** - * JSON with DateTime and Duration support. Values the reviver could not + * JSON with DateTime and Duration support. Values the reader could not * rebuild exactly (class instances, Options, bigints) abort the write instead * of coming back as plain objects. */ @@ -110,15 +117,33 @@ function replacer(this: Record, key: string, value: unknown): u return value; } -function reviver(_key: string, value: unknown): unknown { - if (typeof value !== "object" || value === null || Array.isArray(value)) return value; - if (DATE_MARKER in value) { - const millis = (value as Record)[DATE_MARKER]; - if (typeof millis === "number" && Object.keys(value).length === 1) { - return DateTime.makeUnsafe(millis); +function hasSingleKey(value: object): boolean { + let count = 0; + for (const _key in value) if (++count > 1) return false; + return count === 1; +} + +/** + * Rebuilds DateTime and Duration values in place. A walk over the parsed + * tree is several times faster than a JSON.parse reviver, which is called + * for every primitive too. + */ +function revive(value: object): unknown { + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index++) { + const item: unknown = value[index]; + if (item !== null && typeof item === "object") value[index] = revive(item); } - } else if (DURATION_MARKER in value && Object.keys(value).length === 1) { - return decodeDuration((value as Record)[DURATION_MARKER]) ?? value; + return value; + } + const record = value as Record; + const millis = record[DATE_MARKER]; + if (typeof millis === "number" && hasSingleKey(record)) return DateTime.makeUnsafe(millis); + const duration = record[DURATION_MARKER]; + if (duration !== undefined && hasSingleKey(record)) return decodeDuration(duration) ?? value; + for (const key in record) { + const item = record[key]; + if (item !== null && typeof item === "object") record[key] = revive(item); } return value; } @@ -161,7 +186,8 @@ function isCatalogEntry(value: unknown): value is BootCatalogEntry { } function parseDocument(raw: string): BootSnapshot | null { - const parsed: unknown = JSON.parse(raw, reviver); + const plain: unknown = JSON.parse(raw); + const parsed = plain !== null && typeof plain === "object" ? revive(plain) : plain; if ( !isRecord(parsed) || parsed.format !== FORMAT || @@ -253,6 +279,49 @@ function encode(value: object): string | null { return json; } +/** + * Live updates replace a few rows and reuse the rest, so each project and + * thread row is encoded once and cached by identity. + */ +function encodeShell(snapshot: OrchestrationV2ShellSnapshot): string | null { + const cached = encoded.get(snapshot); + if (cached !== undefined) return cached; + const fields: string[] = []; + let json: string | null = null; + try { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + const rows: string[] = []; + for (const row of value as ReadonlyArray) { + const encodedRow = encode(row); + if (encodedRow === null) throw new UnsupportedBootValue(`row in ${key}`); + rows.push(encodedRow); + } + fields.push(`${JSON.stringify(key)}:[${rows.join(",")}]`); + } else { + fields.push(`${JSON.stringify(key)}:${JSON.stringify(value, replacer)}`); + } + } + json = `{${fields.join(",")}}`; + } catch { + json = null; + } + encoded.set(snapshot, json); + return json; +} + +/** Characters every other localStorage key of this origin holds. */ +function otherKeysLength(store: Storage): number { + let total = 0; + for (let index = 0; index < store.length; index++) { + const key = store.key(index); + if (key === null || key === STORAGE_KEY) continue; + total += key.length + (store.getItem(key)?.length ?? 0); + } + return total; +} + function flush() { if (flushTimer !== null) { clearTimeout(flushTimer); @@ -268,7 +337,7 @@ function flush() { const parts: string[] = []; for (const [environmentId, snapshot] of shells) { if (!known.has(environmentId)) continue; - const json = encode(snapshot); + const json = encodeShell(snapshot); if (json !== null) parts.push(`${JSON.stringify(environmentId)}:${json}`); } const configParts: string[] = []; @@ -277,16 +346,19 @@ function flush() { const json = encode(config); if (json !== null) configParts.push(`${JSON.stringify(environmentId)}:${json}`); } - const document = + const serialized = `{"format":${FORMAT},"build":${JSON.stringify(BUILD_ID)},"savedAt":${Date.now()},` + `"catalog":${JSON.stringify(catalog)},"shells":{${parts.join(",")}},` + `"serverConfigs":{${configParts.join(",")}}}`; try { - if (parts.length === 0 || document.length > MAX_DOCUMENT_LENGTH) { + if ( + parts.length === 0 || + otherKeysLength(store) + RESERVED_FOR_OTHER_KEYS + serialized.length > ORIGIN_CHARACTER_BUDGET + ) { store.removeItem(STORAGE_KEY); return; } - store.setItem(STORAGE_KEY, document); + store.setItem(STORAGE_KEY, serialized); } catch { // Quota or a disabled store: an older document must not outlive this one. try { @@ -306,6 +378,8 @@ function scheduleFlush() { if (document.visibilityState === "hidden") flush(); }); } + // Shell saves are already throttled to one per 10 s; this only batches the + // shell, config and catalog that change together. if (flushTimer === null) flushTimer = setTimeout(flush, 1_500); } From 674e6ff97b2dccaa567c7556d027c5b119282e66 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 04:06:55 +0000 Subject: [PATCH 039/241] fix(web): label the send button Connecting during a first connection attempt The send button called an environment disconnected while its first connection attempt was still in progress, which a composer painted from the boot snapshot now shows on every open. A first attempt shows the existing Connecting state; retries still read as disconnected. --- apps/web/src/components/chat/ChatComposer.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e9911a2c0deb6..65840afa21984 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2161,6 +2161,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; const noProviderAvailable = selectedProviderEntry === undefined && multipleModelSelections === null; + // A first connection attempt is connecting, not disconnected. + const environmentConnecting = environmentUnavailable?.connection.phase === "connecting"; // Before the catalog arrives, every thread resolves to "no provider". Send // stays blocked either way; only the chrome waits, keeping the picker with // the thread's own selection instead of swapping in the setup button and @@ -6865,9 +6867,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptHasText={false} isSendBusy={isSendBusy} sendDisabledReason={sendDisabledReason} - isConnecting={isConnecting} + isConnecting={isConnecting || environmentConnecting} isEnvironmentUnavailable={ - environmentUnavailable !== null || + (environmentUnavailable !== null && !environmentConnecting) || noProviderAvailable || projectSelectionRequired } @@ -7552,9 +7554,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptHasText={false} isSendBusy={isSendBusy} sendDisabledReason={sendDisabledReason} - isConnecting={isConnecting} + isConnecting={isConnecting || environmentConnecting} isEnvironmentUnavailable={ - environmentUnavailable !== null || + (environmentUnavailable !== null && !environmentConnecting) || noProviderAvailable || projectSelectionRequired } @@ -7678,9 +7680,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptHasText={prompt.trim().length > 0} isSendBusy={isSendBusy} sendDisabledReason={sendDisabledReason} - isConnecting={isConnecting} + isConnecting={isConnecting || environmentConnecting} isEnvironmentUnavailable={ - environmentUnavailable !== null || + (environmentUnavailable !== null && !environmentConnecting) || noProviderAvailable || projectSelectionRequired } From b35b4d8e8098cdc56d081a68b47721c3642f8926 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:40:40 +0000 Subject: [PATCH 040/241] perf(web): connect while the first screen paints from the snapshot Connecting waited until the snapshot screen had painted, so the websocket opened and the session answered later than before the snapshot existed, and sending unlocked later. The live shells now subscribe at boot, as they did before, so the connection's network steps run alongside the first render. The boot hold only keeps the first screen reading snapshot data, so replies arriving meanwhile do not re-render or restart it. When the session is already live as the chat view mounts, the screen switches to live data and renders it at once; otherwise the chat view renders from the snapshot as a transition that yields to the connection. The landing draft asks for t3.json as soon as it opens, so the read goes out with the session. --- apps/web/src/bootLandingDraft.ts | 5 +++ apps/web/src/components/ThreadRouteView.tsx | 35 ++++++++++++++++----- apps/web/src/connection/catalog.ts | 15 +++------ apps/web/src/routes/_chat.tsx | 7 +++-- apps/web/src/state/shell.ts | 23 ++++++++++---- 5 files changed, 59 insertions(+), 26 deletions(-) diff --git a/apps/web/src/bootLandingDraft.ts b/apps/web/src/bootLandingDraft.ts index 3a00bea9799f7..36c413ca4ca87 100644 --- a/apps/web/src/bootLandingDraft.ts +++ b/apps/web/src/bootLandingDraft.ts @@ -16,6 +16,7 @@ import { useComposerDraftStore, } from "./composerDraftStore"; import { resolveNewDraftStartFromOrigin } from "./lib/chatThreadActions"; +import { readT3ProjectFile } from "./lib/t3ProjectFileDefaults"; import { newDraftId, newThreadId } from "./lib/utils"; import { ensureClientSettingsHydrated, getClientSettings } from "./hooks/useSettings"; import { @@ -95,5 +96,9 @@ export async function resolveLandingDraft(): Promise { const draft = useComposerDraftStore.getState().getDraftSession(draftId); if (draft === null) return null; setPendingLandingDraft({ draftId, projectRef, openedWith: landingDraftContext(draft) }); + // The defaults read t3.json once the session is ready; asking now lets the + // query go out with the session instead of after the first render, and the + // query cache answers the later read. + void readT3ProjectFile(project.environmentId, project.workspaceRoot).catch(() => undefined); return draftId; } diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 057437032c564..a69949cee6270 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -1,5 +1,5 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; import { Activity, @@ -8,6 +8,7 @@ import { useCallback, useDeferredValue, useEffect, + useEffectEvent, useLayoutEffect, useMemo, useRef, @@ -37,6 +38,7 @@ import { environmentShell } from "../state/shell"; import { environmentThreadDetails } from "../state/threads"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { flushSync } from "react-dom"; +import { environmentSession } from "../state/session"; import { isBootHeld, releaseBootHoldAfterPaint, retainBootHold } from "../connection/catalog"; import { buildThreadRouteParams, @@ -77,13 +79,22 @@ let bootChatViewDeferred = false; /** * On a boot painting from its snapshot, the first frame shows the sidebar and * this view's frame, and the chat view renders right after it, so its large - * first render does not hold back that frame. It renders in one task ahead of - * the connection's work: rendering it as a sliced transition let decoding - * interleave and made the composer later. + * first render does not hold back that frame. + * + * If the environment's session is already live by then, the screen switches + * to live data and the chat view renders with it at once. Otherwise it + * renders from the snapshot as a transition, which yields so the connection + * keeps making progress, and the switch to live data waits until it has + * painted. */ -function useBootDeferredChatView(): boolean { +function useBootDeferredChatView(environmentId: EnvironmentId | null): boolean { const [ready, setReady] = useState(() => bootChatViewDeferred || !isBootHeld()); const pendingReleaseRef = useRef<(() => void) | null>(null); + const sessionLive = useEffectEvent( + () => + environmentId !== null && + appAtomRegistry.get(environmentSession.initialConfigValueAtom(environmentId)) !== null, + ); useEffect(() => { if (ready) return; bootChatViewDeferred = true; @@ -92,9 +103,15 @@ function useBootDeferredChatView(): boolean { const mount = () => { if (mounted) return; mounted = true; + if (sessionLive()) { + // Nothing left to wait for: render it now, in one task. + release(); + flushSync(() => setReady(true)); + return; + } // Released after the chat view's own commit paints, or on unmount. pendingReleaseRef.current = release; - flushSync(() => setReady(true)); + startTransition(() => setReady(true)); }; const frame = requestAnimationFrame(() => setTimeout(mount, 0)); const timer = setTimeout(mount, 100); @@ -145,7 +162,11 @@ export const ThreadRouteView = memo(function ThreadRouteView({ const draftSession = useComposerDraftStore((store) => draftId === null ? null : store.getDraftSession(draftId), ); - const chatViewReady = useBootDeferredChatView(); + const chatViewReady = useBootDeferredChatView( + target.kind === "server" + ? target.threadRef.environmentId + : (draftSession?.environmentId ?? null), + ); const threadRefs = useThreadRefs(); // The server thread this view is about: the route's own ref, or the draft's // reserved ref once the server knows it. diff --git a/apps/web/src/connection/catalog.ts b/apps/web/src/connection/catalog.ts index 8733912600c88..1272b6831d709 100644 --- a/apps/web/src/connection/catalog.ts +++ b/apps/web/src/connection/catalog.ts @@ -92,31 +92,24 @@ function hasPrimaryEntry(catalog: EnvironmentCatalogState): boolean { /** * True from a boot with a snapshot until its first screen is painted. While - * held, the boot-aware atoms serve only snapshot data and never touch the - * live ones, so the first frames render before connecting, IndexedDB and - * server decoding compete with them for the main thread. + * held, the boot-aware atoms serve only snapshot data, so connection replies + * arriving meanwhile do not re-render or restart the first screen's renders; + * the screen switches to live data once, after it has painted. Connecting + * itself does not wait for it. */ export const bootHoldAtom = Atom.make(readBootSnapshot() !== null).pipe( Atom.keepAlive, Atom.withLabel("web-boot-hold"), ); -const bootHoldListeners: Array<() => void> = []; let bootHoldRetainers = 0; export function isBootHeld(): boolean { return appAtomRegistry.get(bootHoldAtom); } -/** Runs `start` once the boot hold is released, or now when there is none. */ -export function afterBootHold(start: () => void): void { - if (isBootHeld()) bootHoldListeners.push(start); - else start(); -} - function releaseBootHold(): void { if (!isBootHeld()) return; appAtomRegistry.set(bootHoldAtom, false); - for (const start of bootHoldListeners.splice(0)) start(); } /** diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 141f3d57e088a..e0f8eaa3a83a4 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -18,6 +18,7 @@ import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread, useNewThreadHandler } from "../hooks/useHandleNewThread"; import { clearPendingLandingDraft, usePendingLandingDraft } from "../bootLanding"; +import { bootHoldAtom } from "../connection/catalog"; import { useScratchProject } from "../hooks/useScratchProject"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; @@ -225,15 +226,17 @@ function LandingDraftDefaults() { ? NO_LIVE_CONFIG_ATOM : environmentSession.initialConfigValueAtom(landing.projectRef.environmentId), ); + // Held boot screens still read the snapshot's settings; the defaults need the server's. + const bootHeld = useAtomValue(bootHoldAtom); const startedDraftIdRef = useRef(null); useEffect(() => { - if (landing === null || liveConfig === null) return; + if (landing === null || liveConfig === null || bootHeld) return; if (startedDraftIdRef.current === landing.draftId) return; startedDraftIdRef.current = landing.draftId; void handleNewThread(landing.projectRef, { replace: true, landing }) .catch(() => undefined) .finally(() => clearPendingLandingDraft(landing.draftId)); - }, [handleNewThread, landing, liveConfig]); + }, [bootHeld, handleNewThread, landing, liveConfig]); return null; } diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index dbdbb71bfac6e..2a5cba2ba3c1b 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -18,7 +18,6 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { readBootSnapshot } from "../bootSnapshot"; import { - afterBootHold, bootHoldAtom, environmentCatalog, readBootCatalogState, @@ -164,6 +163,20 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { return true; }).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped")); +/** + * Keeps every enabled environment's live shell subscribed, which is what + * connects it. Unlike the boot-aware atoms above, it reads the live atoms even + * while the first screen paints from the boot snapshot, so connecting runs + * alongside that paint instead of after it. + */ +const liveEnvironmentShellsAtom = Atom.make((get) => { + const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); + if (Option.isNone(catalog)) return; + for (const environmentId of enabledEnvironmentIds(catalog.value)) { + get(liveEnvironmentShell.stateAtom(environmentId)); + } +}).pipe(Atom.withLabel("web-live-environment-shells")); + let environmentShellsStarted = false; /** @@ -175,11 +188,9 @@ let environmentShellsStarted = false; export function startEnvironmentShells(): void { if (environmentShellsStarted) return; environmentShellsStarted = true; - // A boot painting from its snapshot connects after the first frame. - afterBootHold(() => { - appAtomRegistry.mount(allEnvironmentShellsBootstrappedAtom); - startBootCatalogRecorder(); - }); + appAtomRegistry.mount(liveEnvironmentShellsAtom); + appAtomRegistry.mount(allEnvironmentShellsBootstrappedAtom); + startBootCatalogRecorder(); } /** Cached or missing snapshots cannot establish that a saved project no longer exists. */ From a4f4f2d04567eb7771d3f1dc85733b4d3d732d0b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 06:00:40 +0000 Subject: [PATCH 041/241] perf(web): apply landing draft defaults as soon as the session answers The landing draft's defaults waited until the first screen switched from the snapshot to live data, which comes after the chat view has painted. The handler now takes the session's own server settings, so the defaults land when the session answers, while the screen may still show the snapshot. --- apps/web/src/hooks/useHandleNewThread.ts | 13 +++++++++++-- apps/web/src/routes/_chat.tsx | 14 ++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 6a14f04cf59b3..f1c8360dad4ab 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -4,7 +4,12 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + type ScopedProjectRef, + type ServerSettings, + type ThreadId, +} from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -83,6 +88,8 @@ export function useNewThreadHandler() { * text stays, and a workspace context the user changed meanwhile wins. */ landing?: PendingLandingDraft; + /** The server's settings when the caller holds them before this view does. */ + serverSettings?: ServerSettings; }, // Which draft the thread ended up in, so a caller that has something to put in it — a // prepared checkout, a task to write — addresses that one rather than looking the project @@ -90,7 +97,9 @@ export function useNewThreadHandler() { ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); const targetServerSettings = - environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; + options?.serverSettings ?? + environmentServerConfigs.get(projectRef.environmentId)?.settings ?? + DEFAULT_SERVER_SETTINGS; const { getComposerDraft, getDraftSessionByLogicalProjectKey, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index e0f8eaa3a83a4..ed224219823e3 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -18,7 +18,6 @@ import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread, useNewThreadHandler } from "../hooks/useHandleNewThread"; import { clearPendingLandingDraft, usePendingLandingDraft } from "../bootLanding"; -import { bootHoldAtom } from "../connection/catalog"; import { useScratchProject } from "../hooks/useScratchProject"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; @@ -226,17 +225,20 @@ function LandingDraftDefaults() { ? NO_LIVE_CONFIG_ATOM : environmentSession.initialConfigValueAtom(landing.projectRef.environmentId), ); - // Held boot screens still read the snapshot's settings; the defaults need the server's. - const bootHeld = useAtomValue(bootHoldAtom); const startedDraftIdRef = useRef(null); useEffect(() => { - if (landing === null || liveConfig === null || bootHeld) return; + if (landing === null || liveConfig === null) return; if (startedDraftIdRef.current === landing.draftId) return; startedDraftIdRef.current = landing.draftId; - void handleNewThread(landing.projectRef, { replace: true, landing }) + // The screen may still show the snapshot's settings; the defaults use the server's. + void handleNewThread(landing.projectRef, { + replace: true, + landing, + serverSettings: liveConfig.settings, + }) .catch(() => undefined) .finally(() => clearPendingLandingDraft(landing.draftId)); - }, [bootHeld, handleNewThread, landing, liveConfig]); + }, [handleNewThread, landing, liveConfig]); return null; } From acd19446ce2b197493b241053209a6c04d1f23d3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 06:13:21 +0000 Subject: [PATCH 042/241] perf(web): render the boot chat view with live data when the session answers quickly When the session was not live yet as the chat view mounted, the chat view rendered from the snapshot alongside the connection's work and then re-rendered with live data, so sending unlocked well after the composer appeared. The chat view now waits up to 750 ms for the session and renders once with live data; a starting desktop backend or a slow tunnel still gets the snapshot composer after that wait. --- apps/web/src/components/ThreadRouteView.tsx | 68 +++++++++++++++------ 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index a69949cee6270..1fafcdf77a38a 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -76,48 +76,76 @@ function trackUserInput() { let bootChatViewDeferred = false; +// How long the chat view waits for the session before rendering from the +// snapshot. A local server answers well within it; a starting desktop backend +// or a slow tunnel does not, and the composer appears without it. +const BOOT_SESSION_WAIT_MS = 750; + /** * On a boot painting from its snapshot, the first frame shows the sidebar and - * this view's frame, and the chat view renders right after it, so its large - * first render does not hold back that frame. + * this view's frame, and the chat view renders after it, so its large first + * render does not hold back that frame. * - * If the environment's session is already live by then, the screen switches - * to live data and the chat view renders with it at once. Otherwise it - * renders from the snapshot as a transition, which yields so the connection - * keeps making progress, and the switch to live data waits until it has - * painted. + * If the environment's session answers within BOOT_SESSION_WAIT_MS, the + * screen switches to live data and the chat view renders with it in one + * task, with nothing left to redo. Otherwise it renders from the snapshot as + * a transition, which yields so the connection keeps making progress, and the + * switch to live data waits until it has painted. */ function useBootDeferredChatView(environmentId: EnvironmentId | null): boolean { const [ready, setReady] = useState(() => bootChatViewDeferred || !isBootHeld()); const pendingReleaseRef = useRef<(() => void) | null>(null); - const sessionLive = useEffectEvent( - () => - environmentId !== null && - appAtomRegistry.get(environmentSession.initialConfigValueAtom(environmentId)) !== null, + const sessionConfigAtom = useEffectEvent(() => + environmentId === null ? null : environmentSession.initialConfigValueAtom(environmentId), ); useEffect(() => { if (ready) return; bootChatViewDeferred = true; const release = retainBootHold(); let mounted = false; - const mount = () => { + let stopWaiting = () => {}; + const renderLive = () => { if (mounted) return; mounted = true; - if (sessionLive()) { - // Nothing left to wait for: render it now, in one task. - release(); - flushSync(() => setReady(true)); - return; - } + stopWaiting(); + release(); + flushSync(() => setReady(true)); + }; + const renderFromSnapshot = () => { + if (mounted) return; + mounted = true; + stopWaiting(); // Released after the chat view's own commit paints, or on unmount. pendingReleaseRef.current = release; startTransition(() => setReady(true)); }; - const frame = requestAnimationFrame(() => setTimeout(mount, 0)); - const timer = setTimeout(mount, 100); + const afterFirstFrame = () => { + const configAtom = sessionConfigAtom(); + if (configAtom === null) return renderFromSnapshot(); + if (appAtomRegistry.get(configAtom) !== null) return renderLive(); + // A task later, so the requests the session sends as it starts go out + // before this render instead of queuing behind it. + const unsubscribe = appAtomRegistry.subscribe(configAtom, (config) => { + if (config !== null) setTimeout(renderLive, 0); + }); + const timeout = setTimeout(renderFromSnapshot, BOOT_SESSION_WAIT_MS); + stopWaiting = () => { + unsubscribe(); + clearTimeout(timeout); + }; + }; + let started = false; + const start = () => { + if (started) return; + started = true; + afterFirstFrame(); + }; + const frame = requestAnimationFrame(() => setTimeout(start, 0)); + const timer = setTimeout(start, 100); return () => { cancelAnimationFrame(frame); clearTimeout(timer); + stopWaiting(); if (!mounted) release(); }; }, [ready]); From 736368a2b1781a47b7ad63771d5da1f072888ab4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 13:25:46 +0000 Subject: [PATCH 043/241] perf(web): paint from the boot snapshot only on reloads and desktop windows A new browser tab evaluates the app graph for long enough that the cache and the session are ready by its first render, and painting the snapshot first there only added work. The snapshot paint, the early landing draft and the early render before the session check now apply to reloads, which render long before the server answers, and to desktop windows, whose backend may still be starting. Other boots start as before; the snapshot is still written for the next reload, in an idle moment. The live chat view now renders a task after the switch to live data, so neither waits on the other and its effects run after it paints. --- apps/web/src/bootSnapshot.ts | 59 ++++++++++++++++++--- apps/web/src/components/ThreadRouteView.tsx | 6 ++- apps/web/src/main.tsx | 14 ++--- 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/apps/web/src/bootSnapshot.ts b/apps/web/src/bootSnapshot.ts index 334afc3875f7e..07840949d5a0a 100644 --- a/apps/web/src/bootSnapshot.ts +++ b/apps/web/src/bootSnapshot.ts @@ -223,11 +223,7 @@ function parseDocument(raw: string): BootSnapshot | null { let loaded = false; let bootSnapshot: BootSnapshot | null = null; -/** - * The snapshot this page booted with, read once. Null when there is none, it - * came from another build, or it was discarded. - */ -export function readBootSnapshot(): BootSnapshot | null { +function loadStoredBootSnapshot(): BootSnapshot | null { if (loaded) return bootSnapshot; loaded = true; try { @@ -239,6 +235,42 @@ export function readBootSnapshot(): BootSnapshot | null { return bootSnapshot; } +/** + * Whether this page paints from the snapshot: a reload, which renders long + * before the session and the cache could answer, or a desktop window, whose + * backend may still be starting. A new browser tab loads the app graph for + * long enough that the cache and the session are ready when it renders, so it + * starts as it always has. + */ +function bootPaintApplies(): boolean { + if (typeof window === "undefined") return false; + if (window.desktopBridge !== undefined) return true; + try { + const navigation = performance.getEntriesByType("navigation")[0] as + | PerformanceNavigationTiming + | undefined; + return navigation?.type === "reload"; + } catch { + return false; + } +} + +let paintChecked = false; +let paintApplies = false; + +/** + * The snapshot this page paints its first screen from, read once. Null when + * this boot does not paint from a snapshot, there is none, it came from + * another build, or it was discarded. + */ +export function readBootSnapshot(): BootSnapshot | null { + if (!paintChecked) { + paintChecked = true; + paintApplies = bootPaintApplies(); + } + return paintApplies ? loadStoredBootSnapshot() : null; +} + // ── Writing ──────────────────────────────────────────────────────────────── let catalog: ReadonlyArray | null = null; @@ -255,7 +287,7 @@ let discarded = false; function seedFromBootSnapshot() { if (seeded) return; seeded = true; - const initial = readBootSnapshot(); + const initial = loadStoredBootSnapshot(); if (initial === null) return; catalog ??= initial.catalog; for (const [environmentId, snapshot] of initial.shells) { @@ -379,8 +411,17 @@ function scheduleFlush() { }); } // Shell saves are already throttled to one per 10 s; this only batches the - // shell, config and catalog that change together. - if (flushTimer === null) flushTimer = setTimeout(flush, 1_500); + // shell, config and catalog that change together, then waits for an idle + // moment so the write never lands in the middle of startup work. + if (flushTimer === null) { + flushTimer = setTimeout(() => { + if (typeof requestIdleCallback === "function") { + requestIdleCallback(flush, { timeout: 5_000 }); + } else { + flush(); + } + }, 1_500); + } } /** Called after the environment shell cache saved this snapshot. */ @@ -425,6 +466,8 @@ export function discardBootSnapshot(): void { discarded = true; bootSnapshot = null; loaded = true; + paintChecked = true; + paintApplies = false; if (flushTimer !== null) clearTimeout(flushTimer); flushTimer = null; try { diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 1fafcdf77a38a..a6835e0ca983b 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -37,7 +37,6 @@ import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; import { environmentThreadDetails } from "../state/threads"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { flushSync } from "react-dom"; import { environmentSession } from "../state/session"; import { isBootHeld, releaseBootHoldAfterPaint, retainBootHold } from "../connection/catalog"; import { @@ -108,8 +107,11 @@ function useBootDeferredChatView(environmentId: EnvironmentId | null): boolean { if (mounted) return; mounted = true; stopWaiting(); + // The screen switches to live data in this task and the chat view + // renders in the next, so neither waits on the other's long task, and + // its effects run after it paints. release(); - flushSync(() => setReady(true)); + setTimeout(() => setReady(true), 0); }; const renderFromSnapshot = () => { if (mounted) return; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7988ab25e2551..01e53c3884b7a 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,6 +7,7 @@ import "./index.css"; import { prepareProviderAuthDelivery } from "./providerAuthDelivery"; import { ensureClientSettingsHydrated } from "./hooks/useSettings"; import { projectFaviconCache } from "./assets/projectFaviconCache"; +import { readBootSnapshot } from "./bootSnapshot"; import { isElectron } from "./env"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter, preloadInitialRouteChunks } from "./router"; @@ -21,12 +22,13 @@ import { restoreStackTraces } from "./lib/bootStackTraces"; // Every import above has evaluated; startup code runs with full stacks. restoreStackTraces(); prepareProviderAuthDelivery(); -// The first-run gate renders nothing until saved settings load; start now so -// the first commit already has them. -void ensureClientSettingsHydrated().catch(() => undefined); -// Project icons read this cache synchronously, so a sidebar painted from the -// boot snapshot shows them instead of placeholders that swap later. -void projectFaviconCache.hydrate(); +if (readBootSnapshot() !== null) { + // The first-run gate renders nothing until saved settings load, and project + // icons read their cache synchronously; start both now so a first screen + // painted from the boot snapshot already has them. + void ensureClientSettingsHydrated().catch(() => undefined); + void projectFaviconCache.hydrate(); +} // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); From 61d646ca0902595c1d1accfb91cb7aaac5720b40 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 07:58:12 +0000 Subject: [PATCH 044/241] perf(web): keep boot snapshot reads off the startup path Recording the live catalog read and parsed the stored snapshot to merge with it, which ran while the connection was being set up on every boot. The stored document is now merged when the write runs, in an idle moment. A session check that rejects this browser also drops the snapshot when the first screen did not paint from it. --- apps/web/src/bootSnapshot.ts | 26 +++++++++++++++++--------- apps/web/src/routes/__root.tsx | 2 ++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/web/src/bootSnapshot.ts b/apps/web/src/bootSnapshot.ts index 07840949d5a0a..a8eba4886761c 100644 --- a/apps/web/src/bootSnapshot.ts +++ b/apps/web/src/bootSnapshot.ts @@ -284,6 +284,11 @@ let discarded = false; // The first write keeps the values this page booted with for environments the // live data has not written yet. +// Environments cleared during this page, which seeding must not bring back. +const forgotten = new Set(); + +// Runs at the first write, in an idle moment, so reading the stored document +// never lands on the startup path. function seedFromBootSnapshot() { if (seeded) return; seeded = true; @@ -291,10 +296,14 @@ function seedFromBootSnapshot() { if (initial === null) return; catalog ??= initial.catalog; for (const [environmentId, snapshot] of initial.shells) { - if (!shells.has(environmentId)) shells.set(environmentId, snapshot); + if (!shells.has(environmentId) && !forgotten.has(environmentId)) { + shells.set(environmentId, snapshot); + } } for (const [environmentId, config] of initial.serverConfigs) { - if (!serverConfigs.has(environmentId)) serverConfigs.set(environmentId, config); + if (!serverConfigs.has(environmentId) && !forgotten.has(environmentId)) { + serverConfigs.set(environmentId, config); + } } } @@ -430,7 +439,7 @@ export function recordBootShell( snapshot: OrchestrationV2ShellSnapshot, ): void { if (shells.get(environmentId) === snapshot) return; - seedFromBootSnapshot(); + forgotten.delete(environmentId); shells.set(environmentId, snapshot); scheduleFlush(); } @@ -438,24 +447,23 @@ export function recordBootShell( /** Called after the server configuration cache saved this config. */ export function recordBootServerConfig(environmentId: EnvironmentId, config: ServerConfig): void { if (serverConfigs.get(environmentId) === config) return; - seedFromBootSnapshot(); + forgotten.delete(environmentId); serverConfigs.set(environmentId, config); scheduleFlush(); } /** The live connection catalog, so the next boot knows which environments to show. */ export function recordBootCatalog(entries: ReadonlyArray): void { - seedFromBootSnapshot(); catalog = entries; scheduleFlush(); } /** Called when an environment's cache is cleared. */ export function forgetBootEnvironment(environmentId: EnvironmentId): void { - seedFromBootSnapshot(); - const removedShell = shells.delete(environmentId); - const removedConfig = serverConfigs.delete(environmentId); - if (removedShell || removedConfig) scheduleFlush(); + forgotten.add(environmentId); + shells.delete(environmentId); + serverConfigs.delete(environmentId); + scheduleFlush(); } /** diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index db44cc44d6911..9aa6e11f4efdb 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -138,6 +138,8 @@ export const Route = createRootRoute({ const authGateState = shouldRenderBeforeAuthGate(location.pathname) ? renderBeforeAuthGate(authGate) : await authGate; + // A snapshot of a workspace this browser can no longer open must not paint later. + if (authGateState.status === "requires-auth") discardBootSnapshot(); // Connect and read cached shells while the route chunks still load. if (authGateState.status === "authenticated") startEnvironmentShells(); if ( From 4e2ddd9e395086cd9a4f819f386bd7f37111236b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 08:18:36 +0000 Subject: [PATCH 045/241] perf(web): hydrate boot-path settings and icons in their own task On a snapshot boot, client settings decoding and the project icon cache started inside the main module's evaluation, its longest task. They now start in the next task, still well before the first render. --- apps/web/src/main.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 01e53c3884b7a..f0eac0cc23e37 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -24,10 +24,13 @@ restoreStackTraces(); prepareProviderAuthDelivery(); if (readBootSnapshot() !== null) { // The first-run gate renders nothing until saved settings load, and project - // icons read their cache synchronously; start both now so a first screen - // painted from the boot snapshot already has them. - void ensureClientSettingsHydrated().catch(() => undefined); - void projectFaviconCache.hydrate(); + // icons read their cache synchronously; start both right after this module + // runs, in their own task, so a first screen painted from the boot snapshot + // already has them. + setTimeout(() => { + void ensureClientSettingsHydrated().catch(() => undefined); + void projectFaviconCache.hydrate(); + }, 0); } // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. From 1fffbd3be5cbfc6d46234c99562b16ffcb1f79c2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 00:28:52 +0000 Subject: [PATCH 046/241] perf(desktop): open the main window without waiting for the login shell The login shell environment import blocked everything after it, so the main window waited for the user's shell to start (100 ms for a bare bash, seconds for a loaded zsh). Import it in the background instead: the primary backend publishes its endpoint and bootstrap token, then waits for the same run in a new beforeSpawn hook before spawning, and bootstrap waits before other spawning work. The updater and the Linux URL handler now configure after the window opens. --- apps/desktop/src/app/DesktopApp.ts | 86 +++++++++++-------- .../backend/DesktopBackendConfiguration.ts | 7 +- .../src/backend/DesktopBackendManager.test.ts | 38 ++++++++ .../src/backend/DesktopBackendManager.ts | 30 ++++++- .../src/backend/DesktopBackendPool.test.ts | 4 + .../desktop/src/backend/DesktopBackendPool.ts | 4 + apps/desktop/src/main.ts | 2 +- .../src/shell/DesktopShellEnvironment.ts | 8 +- docs/internals/overview.md | 6 ++ 9 files changed, 142 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e35c39330a4d9..13cc26f692912 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -168,6 +168,7 @@ const bootstrap = Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; const snapShot = yield* DesktopSnapShot.DesktopSnapShot; const appActivation = yield* DesktopAppActivation.DesktopAppActivation; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; yield* logBootstrapInfo("bootstrap start"); const settings = yield* desktopSettings.get; @@ -188,13 +189,14 @@ const bootstrap = Effect.gen(function* () { yield* (yield* DesktopAppIdentity.DesktopAppIdentity).resolveUserDataPath, ); - yield* snapShot.initialize; - if (!settings.localEnvironmentEnabled) { yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); if (!(yield* Ref.get(state.quitting))) { yield* desktopWindow.createMainIfBackendReady; } + // Snap Shot reads the desktop session variables the login shell provides. + yield* shellEnvironment.installIntoProcess; + yield* snapShot.initialize; return; } @@ -243,22 +245,26 @@ const bootstrap = Effect.gen(function* () { } if (!(yield* Ref.get(state.quitting))) { + const wslOnly = settings.wslOnly === true && settings.wslBackendEnabled === true; // The main window waits for the primary backend. In wsl-only mode that is // the WSL backend, which can be slow to cold-boot — show a "Connecting to // WSL" splash immediately so the app feels responsive instead of presenting // no window until WSL is ready. (Dual mode opens fast off the Windows // primary, so no splash there.) - if (settings.wslOnly === true && settings.wslBackendEnabled === true) { + if (wslOnly) { yield* desktopWindow.showConnectingSplash; + // Resolving the WSL primary's config runs wsl.exe. + yield* shellEnvironment.installIntoProcess; } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); // The renderer loads bundled assets and can connect while the backend // boots. Starting first makes its endpoint and bootstrap credential - // available before the preload reads them. WSL-only retains its splash - // until the primary has a usable endpoint. A failed open is not fatal: - // backend readiness tries again. - if (!(settings.wslOnly === true && settings.wslBackendEnabled === true)) { + // available before the preload reads them; the spawn itself waits for the + // login shell environment. WSL-only retains its splash until the primary + // has a usable endpoint. A failed open is not fatal: backend readiness + // tries again. + if (!wslOnly) { yield* desktopWindow.ensureMain.pipe( Effect.catch((error) => logBootstrapWarning("failed to open main window while the backend starts", { @@ -267,6 +273,9 @@ const bootstrap = Effect.gen(function* () { ), ); } + // Snap Shot reads the desktop session variables the login shell provides. + yield* shellEnvironment.installIntoProcess; + yield* snapShot.initialize; yield* appActivation.start.pipe( Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), @@ -294,35 +303,30 @@ const startup = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; - yield* shellEnvironment.installIntoProcess; const hasCommandLinePasswordStore = preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; - const linuxElectronOptions = - environment.platform === "linux" && !hasCommandLinePasswordStore - ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess( - yield* HostProcess.HomeDirectory, - ) - : preReadyElectronOptions.linux; - if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) { - if ( - linuxElectronOptions.passwordStore !== null || - preReadyElectronOptions.linux?.passwordStore !== null - ) { - yield* electronApp.removeCommandLineSwitch("password-store"); - } - if (linuxElectronOptions.passwordStore !== null) { - yield* electronApp.appendCommandLineSwitch( - "password-store", - linuxElectronOptions.passwordStore, - ); + const configureLinuxPasswordStore = Effect.gen(function* () { + const linuxElectronOptions = + environment.platform === "linux" && !hasCommandLinePasswordStore + ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess( + yield* HostProcess.HomeDirectory, + ) + : preReadyElectronOptions.linux; + if (linuxElectronOptions === null) return; + if (!hasCommandLinePasswordStore) { + if ( + linuxElectronOptions.passwordStore !== null || + preReadyElectronOptions.linux?.passwordStore !== null + ) { + yield* electronApp.removeCommandLineSwitch("password-store"); + } + if (linuxElectronOptions.passwordStore !== null) { + yield* electronApp.appendCommandLineSwitch( + "password-store", + linuxElectronOptions.passwordStore, + ); + } } - } - const userDataPath = yield* appIdentity.resolveUserDataPath; - yield* electronApp.setPath("userData", userDataPath); - yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); - yield* desktopSettings.load; - - if (linuxElectronOptions !== null) { yield* logStartupInfo("linux password store configured", { passwordStore: hasCommandLinePasswordStore ? "command-line" @@ -330,7 +334,18 @@ const startup = Effect.gen(function* () { xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null, xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null, }); - } + }); + // A login shell can take seconds to start, and only spawned processes need + // its environment. The window opens without it; the backend and bootstrap's + // spawning work wait for this same run. + yield* shellEnvironment.installIntoProcess.pipe( + Effect.andThen(configureLinuxPasswordStore), + Effect.forkScoped, + ); + const userDataPath = yield* appIdentity.resolveUserDataPath; + yield* electronApp.setPath("userData", userDataPath); + yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); + yield* desktopSettings.load; yield* appIdentity.configure; yield* lifecycle.register; @@ -350,10 +365,11 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* previewPasskeys.configure; yield* applicationMenu.configure; + yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); + // After the window: nothing above needs the updater or the URL handler. yield* updates.configure; yield* DesktopRemoteUpdates.listen; yield* linuxUrlHandler.register; - yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); const scopedProgram = Effect.scoped( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index b306975beb24b..29e5d9ae001eb 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -567,9 +567,10 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv host: backendExposure.bindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // DesktopApp installs the shell environment into process.env before any - // backend starts, and this child extends it (extendEnv below). WSL gets - // a different environment, so its bootstrap never sets this. + // The primary's spawn waits for the shell environment to be installed into + // process.env (DesktopBackendPool's beforeSpawn), and this child extends it + // (extendEnv below). WSL gets a different environment, so its bootstrap + // never sets this. shellEnvironmentPrepared: true, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, tailscaleServePort: backendExposure.tailscaleServePort, diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index faee379c65e59..525ac263d545e 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -139,6 +139,7 @@ interface MakeInstanceInput { DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"] >; readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; + readonly beforeSpawn?: Effect.Effect; } // Helper that constructs a primary backend instance using the factory @@ -189,6 +190,7 @@ function makeTestInstance(input: MakeInstanceInput) { ...(input.onReady ? { onReady: () => input.onReady! } : {}), ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), + ...(input.beforeSpawn ? { beforeSpawn: input.beforeSpawn } : {}), }); return instance.pipe(Effect.provide(layerServices)); @@ -948,6 +950,42 @@ describe("DesktopBackendManager", () => { ), ); + it.effect("publishes its config before the spawn gate and never spawns once stopped", () => + Effect.scoped( + Effect.gen(function* () { + const spawned = yield* Queue.unbounded(); + const gate = yield* Deferred.make(); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Queue.offer(spawned, undefined).pipe( + Effect.as(makeProcess({ exitCode: Effect.never })), + ), + ), + ); + const instance = yield* makeTestInstance({ + spawnerLayer: layerSpawner, + beforeSpawn: Deferred.await(gate), + }); + + // The endpoint and token are readable while the spawn waits. + yield* instance.start; + assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); + assert.equal(yield* Queue.size(spawned), 0); + + yield* instance.stop().pipe(Effect.timeout("1 second")); + yield* Deferred.succeed(gate, undefined); + yield* Effect.yieldNow; + assert.equal(yield* Queue.size(spawned), 0); + assert.isTrue(Option.isNone((yield* instance.snapshot).activePid)); + + // A later start spawns as soon as the gate is open. + yield* instance.start; + yield* Queue.take(spawned).pipe(Effect.timeout("1 second")); + }), + ), + ); + it.effect("restarts when start is requested during stop teardown", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 6b38dadcbdf85..8529cc0c1e6e9 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -25,6 +25,7 @@ import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -67,6 +68,10 @@ const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); +const STOPPED_BEFORE_SPAWN: BackendProcessExit = { + code: Option.none(), + reason: "stopped before spawn", +}; const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; const { logWarning: logBackendProcessWarning } = DesktopObservability.makeComponentLogger("desktop-backend-process"); @@ -300,6 +305,10 @@ export interface BackendInstanceSpec { // between "fired onReady" and "currentConfig already advanced". readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; + // Awaited after a run's config is published and before its process spawns. + // The primary waits here for the login shell environment, so its endpoint + // and bootstrap token are readable while the shell is still starting. + readonly beforeSpawn?: Effect.Effect; // Fired once when a fatal or bounded preflight failure has exhausted its // retries. Returns true when the callback changed configuration and the // manager should resolve once more; false stops the failed instance. @@ -727,6 +736,21 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); }); + // Waits for spec.beforeSpawn, or reports false once stop closes the run's + // scope first, so a stopped run never spawns. + const awaitBeforeSpawn = (runScope: Scope.Scope): Effect.Effect => { + const beforeSpawn = spec.beforeSpawn; + if (beforeSpawn === undefined) return Effect.succeed(true); + return Effect.gen(function* () { + const stopped = yield* Deferred.make(); + yield* Scope.addFinalizer(runScope, Deferred.succeed(stopped, undefined)); + return yield* Effect.raceFirst( + beforeSpawn.pipe(Effect.as(true)), + Deferred.await(stopped).pipe(Effect.as(false)), + ); + }); + }; + const start: Effect.Effect = Effect.suspend(() => mutex.withPermits(1)( Effect.gen(function* () { @@ -943,7 +967,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ); }); - const program = runBackendProcess({ + const spawnProcess = runBackendProcess({ ...config.value, desktopTelemetryStream: desktopTelemetryPublisher.encoded, // Only a bootstrap that names the browser fds (the local primary) gets them. @@ -1007,7 +1031,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }, ), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), - }).pipe( + }); + const program = awaitBeforeSpawn(runScope).pipe( + Effect.flatMap((spawn) => (spawn ? spawnProcess : Effect.succeed(STOPPED_BEFORE_SPAWN))), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), Scope.provide(runScope), diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index d8d7813e79721..5639e2577964a 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; @@ -84,6 +85,9 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer desktopWindow.handleBackendNotReady, onPreflightFailed: handlePrimaryPreflightFailure, + // The backend and the agents it runs need the login shell's PATH. + beforeSpawn: shellEnvironment.installIntoProcess, }); const instancesRef = yield* SynchronizedRef.make< diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ef00c4d66ed89..82b0c6e198021 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -194,6 +194,7 @@ const layerDesktopAppActivation = DesktopAppActivation.layer.pipe( // DesktopBackendPool.ts header for the full rollout plan. const layerDesktopBackend = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), + Layer.provideMerge(DesktopShellEnvironment.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), Layer.provideMerge(DesktopWslEnvironment.layer), Layer.provideMerge(DesktopWslServerTree.layer), @@ -219,7 +220,6 @@ const layerDesktopApplication = Layer.mergeAll( DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopCliCommand.layer, - DesktopShellEnvironment.layer, layerDesktopSsh, ).pipe( Layer.provideMerge(layerDesktopSnapShot), diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 4fa98261fbccd..b6b5eade57c41 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -64,6 +64,7 @@ export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErr export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, { + /** Imports the login shell's environment into process.env once; later calls wait for that run. */ readonly installIntoProcess: Effect.Effect; } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} @@ -515,7 +516,9 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = + // Runs once. Startup begins it without waiting so the window can open while the + // login shell starts, and the local backend awaits the same run before it spawns. + const installIntoProcess = yield* Effect.cached( installShellEnvironment({ env: process.env, platform: environment.platform, @@ -523,7 +526,8 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), - ); + ), + ); return DesktopShellEnvironment.of({ installIntoProcess }); }); diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 149bcf57dff01..41c3d7022e06c 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -115,6 +115,12 @@ rewriting an identical entry while the portal may be reading it. On Wayland, Ele shortcut-registration result only confirms submission; it does not confirm desktop consent or an active binding. +The main window opens before the login shell's environment is imported, because a user's shell +can take seconds to start. Anything the main process spawns must first await +`DesktopShellEnvironment.installIntoProcess` (it runs once; later calls wait for that run), or the +child inherits the GUI launch's PATH instead of the user's. The primary backend waits in its +`beforeSpawn` hook, after its endpoint and bootstrap token are published for the renderer. + Native modules never load in the Electron main process on the startup path, and the two the snapshot feature keeps are isolated: `@crowecawcaw/xa11y` runs only in forked Node-mode children (`SnapShotAccessibilityWorker`, `RegionSnapShotWorker`) and a worker thread, and `ffi-rs` loads From 70ab4dc987142d467fba6fd4eb2a458cb501244c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 00:28:53 +0000 Subject: [PATCH 047/241] perf(desktop): serve renderer assets with one file read Each t3code:// request ran a stat and then a read on the main process. Read the file directly, serve the root as the SPA entry, and fall back to index.html only when that read fails. --- apps/desktop/src/electron/ElectronProtocol.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 7b2bf8a59fc97..2f9e607916782 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -236,16 +236,19 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( if (assetPath !== root && !assetPath.startsWith(root + path.sep)) { return new Response(null, { status: 404 }); } - const stat = yield* fileSystem.stat(assetPath).pipe(Effect.orElseSucceed(() => null)); - let filePath = assetPath; - if (stat?.type !== "File") { + // One filesystem round trip per startup request: the root is the SPA entry, + // and anything else is read directly, falling back only when that read fails. + const indexPath = path.join(root, "index.html"); + let filePath = assetPath === root ? indexPath : assetPath; + let contents = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null)); + if (contents === null && filePath !== indexPath) { const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false; if (path.extname(assetPath) !== "" && !wantsHtml) { return new Response(null, { status: 404 }); } - filePath = path.join(root, "index.html"); + filePath = indexPath; + contents = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null)); } - const contents = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null)); if (contents === null) return new Response(null, { status: 404 }); return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), { headers: { From b997cfbbc00d137e3ae5c4b9d056bda926d54842 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 00:49:55 +0000 Subject: [PATCH 048/241] docs(desktop): note that bootstrap may open the WSL splash instead of the window --- apps/desktop/src/app/DesktopApp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 13cc26f692912..dc0b8b2794dcb 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -366,7 +366,7 @@ const startup = Effect.gen(function* () { yield* previewPasskeys.configure; yield* applicationMenu.configure; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); - // After the window: nothing above needs the updater or the URL handler. + // Bootstrap has opened the window (or the WSL splash); neither needs these. yield* updates.configure; yield* DesktopRemoteUpdates.listen; yield* linuxUrlHandler.register; From d4a01c258f4fd7edcd3e7788b74cb9bac411b425 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 00:55:26 +0000 Subject: [PATCH 049/241] fix(desktop): wait for the login shell before renderer invoke handlers The window now opens while the login shell starts, so a renderer request could spawn ssh, an editor, or the system opener without the shell's PATH or SSH_AUTH_SOCK. Invoke handlers wait for the same shell environment run first; sync handlers only read state and stay immediate. --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 18 +++++++++++++++++- docs/internals/overview.md | 3 ++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index caed223e90e86..c7d1d90c08c36 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { @@ -83,7 +84,22 @@ import { } from "./methods/cliCommand.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { - const ipc = yield* DesktopIpc.DesktopIpc; + const desktopIpc = yield* DesktopIpc.DesktopIpc; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + // The window opens while the login shell is still starting. Invoke handlers + // can spawn processes (ssh, editors, the system opener) that need its + // environment, so they wait for it; sync handlers only read state. + const ipc = { + handle: (method: DesktopIpc.DesktopIpcMethod) => + desktopIpc.handle({ + channel: method.channel, + handler: (raw, event) => + shellEnvironment.installIntoProcess.pipe( + Effect.andThen(() => method.handler(raw, event)), + ), + }), + handleSync: desktopIpc.handleSync, + }; yield* installNotificationBadge(); yield* PreviewIpc.installPreviewEventForwarding(); diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 41c3d7022e06c..8af4a55e0f28b 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -119,7 +119,8 @@ The main window opens before the login shell's environment is imported, because can take seconds to start. Anything the main process spawns must first await `DesktopShellEnvironment.installIntoProcess` (it runs once; later calls wait for that run), or the child inherits the GUI launch's PATH instead of the user's. The primary backend waits in its -`beforeSpawn` hook, after its endpoint and bootstrap token are published for the renderer. +`beforeSpawn` hook, after its endpoint and bootstrap token are published for the renderer, and +renderer invoke handlers wait in `installDesktopIpcHandlers`. Native modules never load in the Electron main process on the startup path, and the two the snapshot feature keeps are isolated: `@crowecawcaw/xa11y` runs only in forked Node-mode children From 9f66e047185cc2c425f0a14de1730fedc95e93cc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 13:48:48 +0000 Subject: [PATCH 050/241] chore: clear lint warnings the integrated changes added ChatCanvas kept a flushSync import its resize observer no longer uses, and lists its stable timeline ref setter as a dependency. Two tests hoist a schema decoder and copy before reversing. --- apps/server/src/ws.test.ts | 2 +- apps/web/src/components/chat/ChatCanvas.tsx | 2 +- packages/client-runtime/src/rpc/session.test.ts | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index c073b2275e2f7..553afcd3ff37b 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -87,7 +87,7 @@ it.effect("sends config deltas and a conditional reconnect acknowledgement", () if (first[0]?.type !== "snapshot" || first[0].configVersion === undefined) return; const reordered = { ...snapshot, - config: Object.fromEntries(Object.entries(config).reverse()) as ServerConfig, + config: Object.fromEntries(Object.entries(config).toReversed()) as ServerConfig, }; const reconnect = yield* withConfigDeltas(Stream.make(reordered), first[0].configVersion).pipe( Stream.runCollect, diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 7a8d7747ee114..1245c71644d8d 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -7,7 +7,6 @@ import { type ComponentProps, type CSSProperties, } from "react"; -import { flushSync } from "react-dom"; import { useElementState } from "../../hooks/useElementState"; import { ChatCanvasContext } from "./ChatCanvasContext"; import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; @@ -117,6 +116,7 @@ export function ChatCanvas({ detailsCard, reportPreview, clearPreview, + registerTimeline, reportDetailsCard, detailsCardTopInset, ]); diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 201cf44582a11..a597ba296bc44 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -41,6 +41,8 @@ import { applyServerConfigProjection } from "../state/serverConfigProjection.ts" import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; import { applyServerConfigDelta, diffServerConfig } from "@t3tools/shared/serverConfigDelta"; +const decodeServerConfigStreamEvent = Schema.decodeSync(ServerConfigStreamEvent); + type SocketEventType = "open" | "message" | "close" | "error"; type SocketEvent = { readonly code?: number; @@ -313,7 +315,7 @@ describe("RpcSessionFactory", () => { }; const delta = diffServerConfig(previous, next, "changed"); expect(delta.providerChanges[0]?.changes.models).toBeUndefined(); - const wire = Schema.decodeSync(ServerConfigStreamEvent)(encodeServerConfigStreamEvent(delta)); + const wire = decodeServerConfigStreamEvent(encodeServerConfigStreamEvent(delta)); expect(wire.type).toBe("configDelta"); if (wire.type !== "configDelta") return; expect(applyServerConfigDelta(previous, wire)).toEqual(next); From 389801161b7fc2945e86e61873e819a0659ae08d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 14:54:00 +0000 Subject: [PATCH 051/241] test(server): the V1 importer boundary allows the project layers module The importer wiring moved from runtimeLayer.ts to projectLayers.ts when the CLI stopped importing the server runtime. --- apps/server/src/orchestration-v2/V1ImportBoundary.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/V1ImportBoundary.test.ts b/apps/server/src/orchestration-v2/V1ImportBoundary.test.ts index 0bece0f11f577..1f14ab8da493e 100644 --- a/apps/server/src/orchestration-v2/V1ImportBoundary.test.ts +++ b/apps/server/src/orchestration-v2/V1ImportBoundary.test.ts @@ -80,11 +80,11 @@ it("keeps the legacy importer out of reach of new code", () => { ) .map(({ path }) => path) .toSorted(); - // Startup imports pending transcripts, the V2 runtime wires the importer, and - // thread and project services hydrate a V1 transcript before they act on it. + // Startup imports pending transcripts, the V2 project layers wire the importer, + // and thread and project services hydrate a V1 transcript before they act on it. assert.deepEqual(importers, [ "orchestration-v2/ThreadManagementService.ts", - "orchestration-v2/runtimeLayer.ts", + "orchestration-v2/projectLayers.ts", "project/ProjectService.ts", "serverRuntimeStartup.ts", ]); From 5afc1fdb4d83db92ff48f7e4a71cf5d135cae0a9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 15:12:48 +0000 Subject: [PATCH 052/241] test: usage and bundled-dev tests follow the boot identity and stack trace changes An equal server config now keeps its object, so the usage test waits for settings equal to the update instead of the update object itself. The bundled-dev fixture copies the module bootstrap now imports for stack traces. --- apps/web/src/bundledDev.test.ts | 7 ++++++- .../client-runtime/src/state/serverUsage.test.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/web/src/bundledDev.test.ts b/apps/web/src/bundledDev.test.ts index 599b45d22524c..9c72faf866ecb 100644 --- a/apps/web/src/bundledDev.test.ts +++ b/apps/web/src/bundledDev.test.ts @@ -29,7 +29,12 @@ it("initializes React refresh before a shared UI chunk runs in bundled dev", asy try { await NodeFSP.mkdir(NodePath.join(root, "src/lib"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(root, "package.json"), '{"type":"module"}'); - for (const file of ["index.html", "src/bootstrap.ts", "src/lib/bootError.ts"]) { + for (const file of [ + "index.html", + "src/bootstrap.ts", + "src/lib/bootError.ts", + "src/lib/bootStackTraces.ts", + ]) { await NodeFSP.copyFile(new URL(`../${file}`, import.meta.url), NodePath.join(root, file)); } await NodeFSP.writeFile( diff --git a/packages/client-runtime/src/state/serverUsage.test.ts b/packages/client-runtime/src/state/serverUsage.test.ts index 3410fd3506fe6..7367187481f7f 100644 --- a/packages/client-runtime/src/state/serverUsage.test.ts +++ b/packages/client-runtime/src/state/serverUsage.test.ts @@ -190,8 +190,11 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( type: "settingsUpdated", payload: { settings }, }); + // An update equal to the current settings keeps the current object. yield* AtomRegistry.toStream(registry, atoms.settingsValueAtom(TARGET.environmentId)).pipe( - Stream.filter((current) => current === next), + Stream.filter( + (current) => current === next || canonicalJson(current) === canonicalJson(next), + ), Stream.runHead, ); }); @@ -205,6 +208,15 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( }; }); +/** JSON with object keys sorted, so key order does not matter. */ +function canonicalJson(value: unknown): string { + return JSON.stringify(value, (_key, nested: unknown) => + nested !== null && typeof nested === "object" && !Array.isArray(nested) + ? Object.fromEntries(Object.entries(nested).toSorted(([a], [b]) => a.localeCompare(b))) + : nested, + ); +} + function settledSummary( registry: AtomRegistry.AtomRegistry, atom: Atom.Atom>, From ba9ffbed2354ca0c8ff8e1824875391daed7a99c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:05:45 +0000 Subject: [PATCH 053/241] fix(desktop): the Linux password store is chosen once, before Electron starts Chromium reads --password-store and the inherited desktop variables during startup, before any asynchronous work runs; a switch appended later, even before `ready`, is ignored. Startup re-resolved the store after the login shell import and removed or re-appended the switch, which now runs in the background and, with a slow shell, after `ready`. It could never change the backend in use, only make the command line and the startup log disagree with it. The pre-ready layer's synchronous choice from the inherited environment stays the only one, and startup logs it without waiting for the shell. --- apps/desktop/src/app/DesktopApp.ts | 44 +++++-------------- .../src/app/DesktopPreReadyPlatform.ts | 2 +- 2 files changed, 12 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index dc0b8b2794dcb..191e93f2ffa7e 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -35,7 +35,6 @@ import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; @@ -303,45 +302,24 @@ const startup = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; - const hasCommandLinePasswordStore = - preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; - const configureLinuxPasswordStore = Effect.gen(function* () { - const linuxElectronOptions = - environment.platform === "linux" && !hasCommandLinePasswordStore - ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess( - yield* HostProcess.HomeDirectory, - ) - : preReadyElectronOptions.linux; - if (linuxElectronOptions === null) return; - if (!hasCommandLinePasswordStore) { - if ( - linuxElectronOptions.passwordStore !== null || - preReadyElectronOptions.linux?.passwordStore !== null - ) { - yield* electronApp.removeCommandLineSwitch("password-store"); - } - if (linuxElectronOptions.passwordStore !== null) { - yield* electronApp.appendCommandLineSwitch( - "password-store", - linuxElectronOptions.passwordStore, - ); - } - } + // Chromium picks the secret storage backend from --password-store and the + // inherited desktop variables before any asynchronous work runs, so the + // pre-ready layer's synchronous choice is the one in effect. + const linuxElectronOptions = preReadyElectronOptions.linux; + if (linuxElectronOptions !== null) { yield* logStartupInfo("linux password store configured", { - passwordStore: hasCommandLinePasswordStore - ? "command-line" - : (linuxElectronOptions.passwordStore ?? "electron-default"), + passwordStore: + preReadyElectronOptions.linuxPasswordStoreCommandLine !== null + ? "command-line" + : (linuxElectronOptions.passwordStore ?? "electron-default"), xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null, xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null, }); - }); + } // A login shell can take seconds to start, and only spawned processes need // its environment. The window opens without it; the backend and bootstrap's // spawning work wait for this same run. - yield* shellEnvironment.installIntoProcess.pipe( - Effect.andThen(configureLinuxPasswordStore), - Effect.forkScoped, - ); + yield* Effect.forkScoped(shellEnvironment.installIntoProcess); const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 378eb58f160c8..f8495ec686013 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -30,7 +30,7 @@ function readCommandLineSwitchValue( return value.length > 0 ? value : null; } -export const resolveEarlyLinuxElectronOptionsFromProcess = ( +const resolveEarlyLinuxElectronOptionsFromProcess = ( homeDirectory: string, ): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ From 09ad1d3bad1241fd63c6008c2378887669dffe28 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:05:45 +0000 Subject: [PATCH 054/241] perf(desktop): boot invoke handlers that never spawn skip the login shell wait Renderer invoke handlers wait for the login shell import so nothing they spawn misses its PATH. Setting the native theme, reading client settings and getting the local bearer token spawn nothing, and the renderer calls them while it boots: with a 0.8 s login shell, set-theme waited 417 ms. They now answer immediately; the bearer token still waits for the backend, whose spawn waits for the shell. --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 8 +++++--- docs/internals/overview.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c7d1d90c08c36..5a5af3e58db2e 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -98,6 +98,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" Effect.andThen(() => method.handler(raw, event)), ), }), + // For handlers the renderer awaits while it boots that never spawn. + handleWithoutShell: desktopIpc.handle, handleSync: desktopIpc.handleSync, }; yield* installNotificationBadge(); @@ -115,9 +117,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); - yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handleWithoutShell(getLocalEnvironmentBearerToken); - yield* ipc.handle(getClientSettings); + yield* ipc.handleWithoutShell(getClientSettings); yield* ipc.handle(setClientSettings); yield* ipc.handle(getConnectionCatalog); yield* ipc.handle(getSnapShotState); @@ -158,7 +160,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pickFolder); yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); - yield* ipc.handle(setTheme); + yield* ipc.handleWithoutShell(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); yield* ipc.handle(receiveProviderAuthCallback); diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 8af4a55e0f28b..0b2a092134e99 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -120,7 +120,7 @@ can take seconds to start. Anything the main process spawns must first await `DesktopShellEnvironment.installIntoProcess` (it runs once; later calls wait for that run), or the child inherits the GUI launch's PATH instead of the user's. The primary backend waits in its `beforeSpawn` hook, after its endpoint and bootstrap token are published for the renderer, and -renderer invoke handlers wait in `installDesktopIpcHandlers`. +renderer invoke handlers that can spawn wait in `installDesktopIpcHandlers`. Native modules never load in the Electron main process on the startup path, and the two the snapshot feature keeps are isolated: `@crowecawcaw/xa11y` runs only in forked Node-mode children From 6195ecc82a12aaefbe4c07f0915da5952d40ab92 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:14:57 +0000 Subject: [PATCH 055/241] fix(desktop): quitting while the login shell starts no longer waits for it The window now opens before the login shell import finishes, so it can be closed, and the app quit, while bootstrap still waits for that shell. Quit waited until the shell finished (2.7 s with a 4 s profile), and startup then spawned the local backend only to stop it, and registered the Linux URL handler. Bootstrap's shell waits now race the shutdown request: on a quit, bootstrap and the rest of startup start nothing more, and the backend's spawn gate sees the stop. The same quit now completes in 62 ms. --- apps/desktop/src/app/DesktopApp.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 191e93f2ffa7e..97cdcd31b0d30 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -168,6 +168,13 @@ const bootstrap = Effect.gen(function* () { const snapShot = yield* DesktopSnapShot.DesktopSnapShot; const appActivation = yield* DesktopAppActivation.DesktopAppActivation; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + const shutdown = yield* DesktopShutdown.DesktopShutdown; + // The window can open, close and quit the app while the login shell is still + // starting. False when a quit came first: bootstrap starts nothing after it. + const awaitShellEnvironment = Effect.raceFirst( + shellEnvironment.installIntoProcess.pipe(Effect.as(true)), + shutdown.awaitRequest.pipe(Effect.as(false)), + ); yield* logBootstrapInfo("bootstrap start"); const settings = yield* desktopSettings.get; @@ -194,7 +201,7 @@ const bootstrap = Effect.gen(function* () { yield* desktopWindow.createMainIfBackendReady; } // Snap Shot reads the desktop session variables the login shell provides. - yield* shellEnvironment.installIntoProcess; + if (!(yield* awaitShellEnvironment)) return; yield* snapShot.initialize; return; } @@ -253,7 +260,7 @@ const bootstrap = Effect.gen(function* () { if (wslOnly) { yield* desktopWindow.showConnectingSplash; // Resolving the WSL primary's config runs wsl.exe. - yield* shellEnvironment.installIntoProcess; + if (!(yield* awaitShellEnvironment)) return; } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); @@ -273,7 +280,7 @@ const bootstrap = Effect.gen(function* () { ); } // Snap Shot reads the desktop session variables the login shell provides. - yield* shellEnvironment.installIntoProcess; + if (!(yield* awaitShellEnvironment)) return; yield* snapShot.initialize; yield* appActivation.start.pipe( Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), @@ -301,6 +308,7 @@ const startup = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; + const state = yield* DesktopState.DesktopState; // Chromium picks the secret storage backend from --password-store and the // inherited desktop variables before any asynchronous work runs, so the @@ -344,6 +352,8 @@ const startup = Effect.gen(function* () { yield* previewPasskeys.configure; yield* applicationMenu.configure; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); + // A quit while bootstrap waited for the login shell skips the rest. + if (yield* Ref.get(state.quitting)) return; // Bootstrap has opened the window (or the WSL splash); neither needs these. yield* updates.configure; yield* DesktopRemoteUpdates.listen; From c57ea6a1c5ac300dde38e2bc029eb8973b55bf1d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:24:39 +0000 Subject: [PATCH 056/241] perf(desktop): the connection catalog and context menus skip the login shell wait The renderer reads the connection catalog while it boots; with a 0.8 s login shell that read waited 488 ms. The catalog's handlers spawn nothing, and its safeStorage ignores a D-Bus address set after startup (probed with a private session bus: libsecret connects at first use, but only to the address Electron started with), so the shell import cannot change it. Context menus spawn nothing either. Get, set and clear move together so a write cannot land after a later read. --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 5a5af3e58db2e..058edf4b23e11 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -98,7 +98,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" Effect.andThen(() => method.handler(raw, event)), ), }), - // For handlers the renderer awaits while it boots that never spawn. + // For handlers that spawn nothing and read no login shell variables. The + // connection catalog's safeStorage ignores a D-Bus address set after startup. handleWithoutShell: desktopIpc.handle, handleSync: desktopIpc.handleSync, }; @@ -121,7 +122,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleWithoutShell(getClientSettings); yield* ipc.handle(setClientSettings); - yield* ipc.handle(getConnectionCatalog); + yield* ipc.handleWithoutShell(getConnectionCatalog); yield* ipc.handle(getSnapShotState); yield* ipc.handle(setupSnapShot); yield* ipc.handle(previewSnapShotConfig); @@ -134,8 +135,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setSnapShotAnimationDestination); yield* ipc.handle(dismissSnapShotAnimation); yield* ipc.handle(acknowledgeSnapShot); - yield* ipc.handle(setConnectionCatalog); - yield* ipc.handle(clearConnectionCatalog); + yield* ipc.handleWithoutShell(setConnectionCatalog); + yield* ipc.handleWithoutShell(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); yield* ipc.handle(resolveSshHost); @@ -161,7 +162,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); yield* ipc.handleWithoutShell(setTheme); - yield* ipc.handle(showContextMenu); + yield* ipc.handleWithoutShell(showContextMenu); yield* ipc.handle(openExternal); yield* ipc.handle(receiveProviderAuthCallback); yield* ipc.handle(cancelProviderAuthCallback); From 84abbfb0585e754050b3116e7a1dbc9b151437d4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:29:41 +0000 Subject: [PATCH 057/241] fix(server): automatic settlement loses to a pull request sync after its snapshot A pull request host sync is not activity, so it keeps the thread's updatedAt. The auto-settle guard compared only updatedAt, so a settlement decided from a snapshot taken before a sync (a PR reopened while the sweep ran) still applied. The guard now compares the latest of updatedAt and the links' syncedAt, which the sweep also sends as its snapshot time. --- .../src/orchestration-v2/Orchestrator.ts | 22 ++++++++++++++++++- .../ThreadSettlementService.ts | 2 +- .../src/orchestration-v2/runtimeLayer.test.ts | 10 +++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 30a6102aaf346..9af5ab1db1387 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -751,6 +751,25 @@ export function shouldPrepareLegacyImportHandoff(input: { ); } +/** + * The last change automatic settlement decides from: activity, or a pull request host sync, + * which records its time on the link instead of bumping the thread's activity timestamp. + */ +export function autoSettleSnapshotAt(thread: { + readonly updatedAt: DateTime.Utc; + readonly pullRequests?: + | ReadonlyArray<{ readonly snapshot: { readonly syncedAt: string } | null }> + | undefined; +}): DateTime.Utc { + return (thread.pullRequests ?? []).reduce( + (latest, link) => + link.snapshot === null + ? latest + : DateTime.max(latest, DateTime.makeUnsafe(link.snapshot.syncedAt)), + thread.updatedAt, + ); +} + export function appendContextHandoffId( handoffIds: OrchestrationV2ProviderThread["handoffIds"], handoffId: OrchestrationV2ContextHandoff["id"] | null, @@ -10200,7 +10219,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if ( thread.settledOverride !== null || - DateTime.toEpochMillis(thread.updatedAt) > DateTime.toEpochMillis(command.snapshotAt) + DateTime.toEpochMillis(autoSettleSnapshotAt(thread)) > + DateTime.toEpochMillis(command.snapshotAt) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 8c599b23231c7..9a6d79faa352a 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -350,7 +350,7 @@ export const make = Effect.gen(function* () { type: "thread.auto-settle", commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), threadId: thread.id, - snapshotAt: thread.updatedAt, + snapshotAt: Orchestrator.autoSettleSnapshotAt(thread), settledAt, }); return null; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 35e643336f795..96e35a2f60558 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2125,6 +2125,16 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { syncedSnapshot, ); assert.deepEqual(afterSync.updatedAt, beforeSync.updatedAt); + // A sync is not activity, but a settlement decided from the snapshot before it is stale. + const autoSettleBeforeSync = yield* orchestrator + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("branch-pr-auto-settle-before-sync"), + threadId, + snapshotAt: Orchestrator.autoSettleSnapshotAt(beforeSync), + }) + .pipe(Effect.flip); + assert.instanceOf(autoSettleBeforeSync, Orchestrator.OrchestratorDispatchError); yield* orchestrator.dispatch({ type: "thread.pull-request.unlink", commandId: CommandId.make("branch-pr-unlink"), From cda6f2d93ee7a89ee9db489b317866f0d1b1cf74 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:29:41 +0000 Subject: [PATCH 058/241] perf(server): older clients resume across a visit without a snapshot Older clients cannot read compact thread events, so any compact event in their replay window forced a full thread snapshot. Their own visits put a thread.visit-recorded in nearly every window. A visit moves only read state, which their shell stream carries, so their replay now skips it; a pull request link sync still takes the snapshot. --- .../boundedSnapshotTransport.test.ts | 42 +++++++++++++++++++ apps/server/src/ws.ts | 14 ++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts index b35971777fab7..ea3efd3881508 100644 --- a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -393,6 +393,48 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { }), ); + it.effect( + "resumes across a compact visit without a snapshot for legacy and opted-in subscribers", + () => + Effect.gen(function* () { + const at = yield* DateTime.now; + const event: OrchestrationV2DomainEvent = { + id: EventId.make("event:compact-transport:resume-visit"), + type: "thread.visit-recorded", + threadId: PARENT, + occurredAt: at, + payload: { lastVisitedAt: at }, + }; + const events = Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({ + latestAgentSequence: () => Effect.succeed(2), + getAgentReplayStats: () => + Effect.succeed({ eventCount: 1, rawPayloadBytes: 128, hasCreateEvent: false }), + readAgentEvents: () => Stream.make({ sequence: 2, commandId: null, event }), + }); + for (const acceptThreadFieldEvents of [false, true]) { + const head = yield* subscribeOrchestrationV2Thread({ + threadId: PARENT, + afterSequence: 1, + requestCompletionMarker: true, + acceptThreadFieldEvents, + }).pipe( + Effect.flatMap((stream) => Stream.runHead(stream)), + Effect.provide(events), + ); + const first = head._tag === "Some" ? head.value : undefined; + // Legacy subscribers skip the visit (their shell carries read state); opted-in ones replay it. + if (acceptThreadFieldEvents) { + assert.equal( + first?.kind === "event" ? first.event.type : first?.kind, + "thread.visit-recorded", + ); + } else { + assert.equal(first?.kind, "synchronized"); + } + } + }), + ); + describe.each([ ["fork", FORK], ["long run with a retained interrupt request", LONG], diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f19fc355bd419..09a2d613da92e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1019,14 +1019,16 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs // Only replace its bounded replay when a snapshot can exist. if (shell !== null) return yield* snapshotThenLive(); } - const replay = yield* loadReplayThrough(input.afterSequence, highWater); + const storedReplay = yield* loadReplayThrough(input.afterSequence, highWater); + // Older clients cannot read compact thread events. A visit moves only read state, which + // their shell stream carries, so their replay skips it; a pull request sync needs a snapshot. + const replay = + input.acceptThreadFieldEvents === true + ? storedReplay + : storedReplay.filter((item) => item.event.type !== "thread.visit-recorded"); if ( input.acceptThreadFieldEvents !== true && - replay.some( - (item) => - item.event.type === "thread.pull-request-link-synced" || - item.event.type === "thread.visit-recorded", - ) + replay.some((item) => item.event.type === "thread.pull-request-link-synced") ) { return yield* snapshotThenLive(); } From 8f121081a8c7263327ace3434a1a09abac05dae9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:29:42 +0000 Subject: [PATCH 059/241] fix(server): the thread read projection exists when another build took its migration id The migrator skips every id at or below a database's latest recorded one, so a database that recorded another build's 62 or 63 never ran this build's 62, and every shell, settlement and limit-recovery read failed on the missing table. Migration 62 now creates the table and triggers only when they are missing, and runMigrations runs it after every pass. --- apps/server/src/persistence/Migrations.ts | 8 +- .../Migrations/055_OrchestrationV2.test.ts | 30 +++++ .../062_ProjectionThreadReadPayload.ts | 126 ++++++++++-------- 3 files changed, 108 insertions(+), 56 deletions(-) diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 85a3f6b6c9799..537854640c2ff 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -75,7 +75,9 @@ import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts"; import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; import Migration0061 from "./Migrations/061_ProjectionThreadSweepIndexes.ts"; -import Migration0062 from "./Migrations/062_ProjectionThreadReadPayload.ts"; +import Migration0062, { + ensureThreadReadProjection, +} from "./Migrations/062_ProjectionThreadReadPayload.ts"; /** * Migration loader with all migrations defined inline. @@ -239,5 +241,9 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ "Migrations of this build never ran on this database: the migrator skips ids at or below its latest recorded migration.", ).pipe(Effect.annotateLogs({ skipped })); } + // ProjectionStore reads this table, so it must exist even when its migration was skipped. + if (toMigrationInclusive === undefined || toMigrationInclusive >= 62) { + yield* ensureThreadReadProjection; + } return executedMigrations; }); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 766262742c204..832ac183fed43 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -222,6 +222,36 @@ layer("055_OrchestrationV2", (it) => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); + // Another build's migration at 62 or above makes the migrator skip this build's 62. + it.effect("creates the thread read projection when another build took its migration id", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 61 }); + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (63, 'OtherBuild')`; + assert.deepStrictEqual(yield* runMigrations(), []); + assert.deepStrictEqual( + yield* sql` + SELECT name FROM sqlite_master + WHERE name LIKE 'orchestration_v2_projection_thread_reads%' + ORDER BY name + `, + [ + { name: "orchestration_v2_projection_thread_reads" }, + { name: "orchestration_v2_projection_thread_reads_stale_on_insert" }, + { name: "orchestration_v2_projection_thread_reads_stale_on_update" }, + ], + ); + // A later pass leaves the existing projection alone. + yield* sql`INSERT INTO orchestration_v2_projection_thread_reads + (thread_id, compact_payload_json, payload_json) VALUES ('kept', '{}', '{}')`; + yield* runMigrations(); + assert.deepStrictEqual( + yield* sql`SELECT thread_id FROM orchestration_v2_projection_thread_reads`, + [{ thread_id: "kept" }], + ); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), + ); + // Published previews ran PeerLinks as 61, so they never run this build's 61. it.effect("keeps sweeps correct on a preview database that ran PeerLinks as 61", () => { const skippedWarnings: Array = []; diff --git a/apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts b/apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts index 25c4abc19b04a..15dc84ec777a6 100644 --- a/apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts +++ b/apps/server/src/persistence/Migrations/062_ProjectionThreadReadPayload.ts @@ -17,64 +17,80 @@ import { * Another build sharing this database rewrites thread payloads without it. The triggers mark the * read row stale on any thread write, whoever makes it, and readers use the thread payload for a * stale or missing row until this build writes the thread again. + * + * ProjectionStore's readers join this table, so it cannot depend on migration 62 running: a + * database that recorded another build's migration at or above 62 skips it. runMigrations also + * runs this after every migration pass, and it does nothing once the table and triggers exist. */ -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - // Development builds of this change created the table without `source_stale`. It is derived - // from the thread rows, so rebuild it. - yield* sql`DROP TABLE IF EXISTS orchestration_v2_projection_thread_reads`; - yield* sql` - CREATE TABLE orchestration_v2_projection_thread_reads ( - thread_id TEXT PRIMARY KEY NOT NULL, - source_stale INTEGER NOT NULL DEFAULT 0, - compact_payload_json TEXT NOT NULL, - payload_json TEXT NOT NULL - ) - `; - yield* sql`DROP TRIGGER IF EXISTS orchestration_v2_projection_thread_reads_stale_on_insert`; - yield* sql` - CREATE TRIGGER orchestration_v2_projection_thread_reads_stale_on_insert - AFTER INSERT ON orchestration_v2_projection_threads - BEGIN - UPDATE orchestration_v2_projection_thread_reads SET source_stale = 1 - WHERE thread_id = NEW.thread_id; - END - `; - yield* sql`DROP TRIGGER IF EXISTS orchestration_v2_projection_thread_reads_stale_on_update`; - yield* sql` - CREATE TRIGGER orchestration_v2_projection_thread_reads_stale_on_update - AFTER UPDATE OF payload_json ON orchestration_v2_projection_threads - BEGIN - UPDATE orchestration_v2_projection_thread_reads SET source_stale = 1 - WHERE thread_id = NEW.thread_id; - END - `; - const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2AppThreadJson)); - let after = ""; - while (true) { - const rows = yield* sql<{ thread_id: string; payload_json: string }>` - SELECT thread_id, payload_json FROM orchestration_v2_projection_threads - WHERE thread_id > ${after} ORDER BY thread_id LIMIT 100 +const ensureInTransaction = (sql: SqlClient.SqlClient) => + Effect.gen(function* () { + const columns = yield* sql<{ readonly name: string }>` + SELECT name FROM pragma_table_info('orchestration_v2_projection_thread_reads') `; - if (rows.length === 0) break; - for (const row of rows) { - // A payload this build cannot decode keeps no read row; readers use the thread payload. - const encoded = yield* decode(row.payload_json).pipe( - Effect.flatMap((thread) => - Effect.all([ - encodeThreadReadPayload(thread), - encodeThreadReadPayload(compactThreadPullRequests(thread)), - ]), - ), - Effect.option, - ); - if (Option.isNone(encoded)) continue; - const [payload, compact] = encoded.value; + if (!columns.some((column) => column.name === "source_stale")) { + // Development builds of this change created the table without `source_stale`. It is derived + // from the thread rows, so rebuild it. + yield* sql`DROP TABLE IF EXISTS orchestration_v2_projection_thread_reads`; yield* sql` - INSERT INTO orchestration_v2_projection_thread_reads (thread_id, payload_json, compact_payload_json) - VALUES (${row.thread_id}, ${payload}, ${compact}) + CREATE TABLE orchestration_v2_projection_thread_reads ( + thread_id TEXT PRIMARY KEY NOT NULL, + source_stale INTEGER NOT NULL DEFAULT 0, + compact_payload_json TEXT NOT NULL, + payload_json TEXT NOT NULL + ) `; + const decode = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2AppThreadJson), + ); + let after = ""; + while (true) { + const rows = yield* sql<{ thread_id: string; payload_json: string }>` + SELECT thread_id, payload_json FROM orchestration_v2_projection_threads + WHERE thread_id > ${after} ORDER BY thread_id LIMIT 100 + `; + if (rows.length === 0) break; + for (const row of rows) { + // A payload this build cannot decode keeps no read row; readers use the thread payload. + const encoded = yield* decode(row.payload_json).pipe( + Effect.flatMap((thread) => + Effect.all([ + encodeThreadReadPayload(thread), + encodeThreadReadPayload(compactThreadPullRequests(thread)), + ]), + ), + Effect.option, + ); + if (Option.isNone(encoded)) continue; + const [payload, compact] = encoded.value; + yield* sql` + INSERT INTO orchestration_v2_projection_thread_reads (thread_id, payload_json, compact_payload_json) + VALUES (${row.thread_id}, ${payload}, ${compact}) + `; + } + after = rows[rows.length - 1]!.thread_id; + } } - after = rows[rows.length - 1]!.thread_id; - } + yield* sql` + CREATE TRIGGER IF NOT EXISTS orchestration_v2_projection_thread_reads_stale_on_insert + AFTER INSERT ON orchestration_v2_projection_threads + BEGIN + UPDATE orchestration_v2_projection_thread_reads SET source_stale = 1 + WHERE thread_id = NEW.thread_id; + END + `; + yield* sql` + CREATE TRIGGER IF NOT EXISTS orchestration_v2_projection_thread_reads_stale_on_update + AFTER UPDATE OF payload_json ON orchestration_v2_projection_threads + BEGIN + UPDATE orchestration_v2_projection_thread_reads SET source_stale = 1 + WHERE thread_id = NEW.thread_id; + END + `; + }); + +export const ensureThreadReadProjection = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql.withTransaction(ensureInTransaction(sql)); }); + +export default ensureThreadReadProjection; From 19fea5d03673b9d2d44924e8a15d0c955e4188ea Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:29:42 +0000 Subject: [PATCH 060/241] fix(server): db compact does not compare the derived thread read rows A read row stays stale after an older build rewrites its thread, and a replay always writes current rows, so verification failed on every database an older build had touched. The thread rows it derives from are still compared. --- apps/server/src/persistence/DatabaseCompaction.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/persistence/DatabaseCompaction.ts b/apps/server/src/persistence/DatabaseCompaction.ts index 4d7c6a1651107..9004f92cdc520 100644 --- a/apps/server/src/persistence/DatabaseCompaction.ts +++ b/apps/server/src/persistence/DatabaseCompaction.ts @@ -320,9 +320,12 @@ const compact = Effect.fn("DatabaseCompaction.compact")( try { comparison.prepare("ATTACH DATABASE ? AS original_replay").run(beforePath); comparison.prepare("ATTACH DATABASE ? AS compact_replay").run(afterPath); + // Thread reads are derived from thread rows and keep a stale row after another build + // rewrites its thread, which a replay never reproduces. The thread rows are compared. const tables = comparison .prepare(`SELECT name FROM sqlite_schema WHERE type='table' - AND name LIKE 'orchestration_v2_projection_%' AND name <> 'orchestration_v2_projection_metadata'`) + AND name LIKE 'orchestration_v2_projection_%' AND name <> 'orchestration_v2_projection_metadata' + AND name <> 'orchestration_v2_projection_thread_reads'`) .all() .map((row) => String(row.name)); tables.push("orchestration_v2_turn_item_positions"); From a6ee0f59469b3e371b6c0e009ac23f144078be6c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:29:42 +0000 Subject: [PATCH 061/241] fix(server): waking from system sleep is not reported as an event loop stall libuv's clock runs during sleep on macOS and Windows, so waking read as a stall of the whole sleep. Gaps over 5 s are now reported only when ELU active time or CPU time covers at least half of them. Shorter gaps are still reported as read, so startup stalls before the loop starts (ELU reads 0 there) are kept. --- .../observability/EventLoopMonitor.test.ts | 23 ++++++++++++++++++- .../src/observability/EventLoopMonitor.ts | 12 +++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/server/src/observability/EventLoopMonitor.test.ts b/apps/server/src/observability/EventLoopMonitor.test.ts index 13c15cfa29f33..6eaae2ae4c41d 100644 --- a/apps/server/src/observability/EventLoopMonitor.test.ts +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -78,7 +78,28 @@ describe("EventLoopMonitor", () => { activeMs: 100, }; assert.strictEqual(EventLoopMonitor.stallMs(short), 750); - assert.strictEqual(EventLoopMonitor.stallMs({ ...quiet, tickDelayMs: 53_000 }), 53_000); + // Before the loop starts, ELU reads 0 and CPU time shows the loop was busy. + const busyBeforeLoop = { + ...quiet, + activeMs: 0, + usage: { ...quiet.usage, userCPUTime: 52_000_000, systemCPUTime: 900_000 }, + }; + assert.strictEqual( + EventLoopMonitor.stallMs({ ...busyBeforeLoop, tickDelayMs: 53_000 }), + 53_000, + ); assert.isUndefined(EventLoopMonitor.stallMs(quiet)); }); + + it("ignores a long gap the loop spent idle, such as a system sleep", () => { + // Waking from sleep reads as a long gap, but the loop was idle in poll for it. + const asleep: EventLoopMonitor.EventLoopReadings = { + ...stalled, + delayMaxNs: ms(600_000), + activeMs: 900, + }; + assert.isUndefined(EventLoopMonitor.stallMs(asleep)); + assert.isUndefined(EventLoopMonitor.stallMs({ ...quiet, tickDelayMs: 7_200_000 })); + assert.strictEqual(EventLoopMonitor.stallMs({ ...asleep, activeMs: 600_000 }), 599_900); + }); }); diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts index e5bc8c36087fc..27e97df582158 100644 --- a/apps/server/src/observability/EventLoopMonitor.ts +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -12,6 +12,8 @@ import type * as Scope from "effect/Scope"; // short stalls without scheduling JS work at that frequency. const RESOLUTION_MS = 100; const STALL_THRESHOLD_MS = 250; +/** Longer gaps must show the loop was busy for most of them, or they were a system sleep. */ +const SLEEP_CHECK_MS = 5000; const SAMPLE_INTERVAL_MS = 1000; /** One sample interval as Node reports it. Delay in ns, active time in ms, CPU in µs. */ @@ -90,11 +92,15 @@ const makeNodeSampler = Effect.gen(function* () { /** * Returns the stall to report for one sample in ms, or undefined when there was none. */ -export const stallMs = ({ delayMaxNs, tickDelayMs = 0 }: EventLoopReadings) => { +export const stallMs = ({ delayMaxNs, tickDelayMs = 0, activeMs, usage }: EventLoopReadings) => { const delayMs = Math.round(Math.max(delayMaxNs / 1e6 - RESOLUTION_MS, tickDelayMs)); - // ELU's active time can lag the histogram, especially across startup and resets. - // Keep it as diagnostic context rather than letting it suppress an observed gap. if (delayMs <= STALL_THRESHOLD_MS) return undefined; + // libuv's clock keeps running while the system sleeps on macOS and Windows, so waking + // reads as a long gap the loop spent idle. A stall runs code: ELU counts it as active + // once the loop has started, and CPU time counts it before that (ELU reads 0 there). + // Short gaps are reported as read, so startup stalls are never lost to that lag. + const busyMs = Math.max(activeMs, (usage.userCPUTime + usage.systemCPUTime) / 1000); + if (delayMs > SLEEP_CHECK_MS && busyMs < delayMs / 2) return undefined; return delayMs; }; From 3a7c7cd7054d4f5711dcb6237c644afb6e98d2b9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:29:42 +0000 Subject: [PATCH 062/241] fix(server): every update keeps a database backup for its rollback The launcher skipped the backup when the target recorded no new migrations, but a trial can write payloads the previous version cannot read without any migration (new compact event types), and a rolled-back trial then had nothing to restore. Every update now copies the database online while the active server keeps serving; a file the online copy cannot read takes the stopped copy as before. The target preflight that answered whether a backup was needed is gone, and with it the second preflight that ran while the server was down. --- .../server/src/cloud/servicePreflight.test.ts | 39 -------------- apps/server/src/cloud/servicePreflight.ts | 26 +-------- apps/server/src/serviceDatabaseBackup.ts | 25 ++++++--- apps/server/src/serviceLauncher.test.ts | 12 ++--- apps/server/src/serviceLauncher.ts | 53 +++---------------- 5 files changed, 31 insertions(+), 124 deletions(-) diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts index 859ebbccc1ce5..1b9cf8e46c41e 100644 --- a/apps/server/src/cloud/servicePreflight.test.ts +++ b/apps/server/src/cloud/servicePreflight.test.ts @@ -1,9 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Preflight is a standalone Node launcher path; this test checks its readonly ledger. -import * as NodeSqlite from "node:sqlite"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import { migrationManifest } from "../persistence/Migrations.ts"; import { expect, it } from "@effect/vitest"; import { runServicePreflight } from "./servicePreflight.ts"; @@ -35,38 +29,5 @@ it("accepts the current launcher protocol", () => { status: "ready", version: "1.2.3", launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, - needsDatabaseBackup: true, }); }); - -it("skips backup only when the target migration ledger is complete without mutating it", () => { - const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-preflight-")); - const databasePath = NodePath.join(directory, "state.sqlite"); - const database = new NodeSqlite.DatabaseSync(databasePath); - try { - database.exec( - "CREATE TABLE effect_sql_migrations (migration_id INTEGER PRIMARY KEY, name TEXT)", - ); - const insert = database.prepare("INSERT INTO effect_sql_migrations VALUES (?, ?)"); - for (const [id, name] of migrationManifest) insert.run(id, name); - const preflight = () => - runServicePreflight({ - databasePath, - launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, - version: "1.2.3", - }); - expect(preflight()).toMatchObject({ status: "ready", needsDatabaseBackup: false }); - const latest = migrationManifest.at(-1)!; - database.prepare("DELETE FROM effect_sql_migrations WHERE migration_id=?").run(latest[0]); - const missing = database - .prepare("SELECT * FROM effect_sql_migrations ORDER BY migration_id") - .all(); - expect(preflight()).toMatchObject({ status: "ready", needsDatabaseBackup: true }); - expect( - database.prepare("SELECT * FROM effect_sql_migrations ORDER BY migration_id").all(), - ).toEqual(missing); - } finally { - database.close(); - NodeFS.rmSync(directory, { recursive: true, force: true }); - } -}); diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts index dab31762f87ac..ee0f972baa3fc 100644 --- a/apps/server/src/cloud/servicePreflight.ts +++ b/apps/server/src/cloud/servicePreflight.ts @@ -1,6 +1,4 @@ import packageJson from "../../package.json" with { type: "json" }; -import * as NodeSqlite from "node:sqlite"; -import { migrationManifest } from "../persistence/Migrations.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; export type ServicePreflightResult = @@ -8,7 +6,6 @@ export type ServicePreflightResult = readonly status: "ready"; readonly version: string; readonly launcherProtocol: typeof SERVICE_LAUNCHER_PROTOCOL; - readonly needsDatabaseBackup?: boolean; } | { readonly status: "blocked"; @@ -32,25 +29,7 @@ export function runServicePreflight(input: { }; } - let needsDatabaseBackup = true; - try { - const database = new NodeSqlite.DatabaseSync(input.databasePath, { readOnly: true }); - try { - const rows = database.prepare("SELECT migration_id, name FROM effect_sql_migrations").all(); - const recorded = new Map(rows.map((row) => [row.migration_id, row.name])); - needsDatabaseBackup = migrationManifest.some(([id, name]) => recorded.get(id) !== name); - } finally { - database.close(); - } - } catch { - // Missing/unreadable ledgers must take the conservative backup path. - } - return { - status: "ready", - version, - launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, - needsDatabaseBackup, - }; + return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }; } export function decodeServicePreflightResult(value: unknown): ServicePreflightResult | undefined { @@ -67,9 +46,6 @@ export function decodeServicePreflightResult(value: unknown): ServicePreflightRe status: "ready", version: record.version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, - ...(typeof record.needsDatabaseBackup === "boolean" - ? { needsDatabaseBackup: record.needsDatabaseBackup } - : {}), }; } if ( diff --git a/apps/server/src/serviceDatabaseBackup.ts b/apps/server/src/serviceDatabaseBackup.ts index d35660b2ebd48..370cec266aa61 100644 --- a/apps/server/src/serviceDatabaseBackup.ts +++ b/apps/server/src/serviceDatabaseBackup.ts @@ -3,6 +3,21 @@ import * as NodeSqlite from "node:sqlite"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +/** A read transaction on a WAL database, or undefined for a file the online copy cannot read. */ +function openWalReader(databasePath: string) { + let source: NodeSqlite.DatabaseSync | undefined; + try { + source = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + source.exec("PRAGMA busy_timeout = 5000; BEGIN"); + source.prepare("SELECT name FROM sqlite_schema LIMIT 1").get(); + if (source.prepare("PRAGMA journal_mode").get()?.journal_mode === "wal") return source; + } catch { + // Not a database this copy can read; closed below. + } + source?.close(); + return undefined; +} + /** * Hold the source read transaction until the stopped server's WAL is captured. * A WAL reader prevents checkpoint/reset from discarding frames newer than its @@ -11,16 +26,12 @@ import * as NodePath from "node:path"; * Copying just an online snapshot would lose those acknowledged writes on rollback. */ export async function prepareServiceDatabaseBackup(databasePath: string, stagingDir: string) { + const source = openWalReader(databasePath); + // Anything else takes the stopped copy. + if (source === undefined) return undefined; await NodeFSP.rm(stagingDir, { recursive: true, force: true }); await NodeFSP.mkdir(stagingDir, { recursive: true, mode: 0o700 }); - const source = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); try { - source.exec("PRAGMA busy_timeout = 5000; BEGIN"); - source.prepare("SELECT name FROM sqlite_schema LIMIT 1").get(); - const mode = source.prepare("PRAGMA journal_mode").get(); - if (mode?.journal_mode !== "wal") { - throw new Error("Online update backup requires a WAL database."); - } const destination = NodePath.join(stagingDir, "database"); await NodeSqlite.backup(source, destination, { rate: 256 }); // SQLite backup produces a standalone database. Enable WAL before installing diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index afeac885ed03d..d53c748ed82d5 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -114,9 +114,11 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { "PRAGMA journal_mode=WAL; CREATE TABLE records(id INTEGER PRIMARY KEY, value TEXT); INSERT INTO records VALUES(1,'before'); PRAGMA wal_checkpoint(TRUNCATE)", ); const prepared = yield* Effect.acquireRelease( - Effect.tryPromise(() => - prepareServiceDatabaseBackup(sourcePath, path.join(root, "backup")), - ), + Effect.tryPromise(async () => { + const backup = await prepareServiceDatabaseBackup(sourcePath, path.join(root, "backup")); + if (backup === undefined) throw new Error("A WAL database is copied online."); + return backup; + }), (backup) => Effect.sync(() => backup.close()), ); writer.exec( @@ -364,10 +366,6 @@ if (context.update?.status === "pending") { original.close(); const childSource = ` import { DatabaseSync } from "node:sqlite"; -if (process.argv[2] === "__service-preflight") { - console.log(JSON.stringify({status:"ready",version:"1.1.0",launcherProtocol:3,needsDatabaseBackup:true})); - process.exit(0); -} const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); if (context.update?.status === "pending") { const db = new DatabaseSync(context.update.dbPath); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 2012128dc7487..514bdce928985 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -135,43 +135,6 @@ async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate } } -/** Ask the target executable, whose migration manifest may differ from this launcher. */ -async function targetNeedsDatabaseBackup(baseDir: string, pending: PendingServiceUpdate) { - const target = runtimePaths(baseDir, pending.targetVersion); - return new Promise((resolve) => { - NodeChildProcess.execFile( - target.entryPath, - [ - "__service-preflight", - "--database-path", - pending.dbPath, - "--launcher-protocol", - String(SERVICE_LAUNCHER_PROTOCOL), - ], - { timeout: 30_000, maxBuffer: 64 * 1024 }, - (error, stdout) => { - if (error !== null) { - resolve(undefined); - return; - } - try { - const result = JSON.parse(stdout); - resolve( - result.status === "ready" && - result.version === pending.targetVersion && - result.launcherProtocol === SERVICE_LAUNCHER_PROTOCOL && - typeof result.needsDatabaseBackup === "boolean" - ? result.needsDatabaseBackup - : undefined, - ); - } catch { - resolve(undefined); - } - }, - ); - }); -} - const restoreMarkerPath = (baseDir: string, updateId: string) => NodePath.join(databaseBackupDir(baseDir, updateId), RESTORE_MARKER); @@ -438,12 +401,10 @@ export class Launcher { await this.#startTrial(update); } - async #startTrial(pending: PendingServiceUpdate, needsBackup?: boolean): Promise { + async #startTrial(pending: PendingServiceUpdate): Promise { // The previous child is dead here, so all three SQLite files are quiescent. try { - if ((needsBackup ?? (await targetNeedsDatabaseBackup(this.#baseDir, pending))) !== false) { - await backupDatabaseOnce(this.#baseDir, pending); - } + await backupDatabaseOnce(this.#baseDir, pending); } catch { await this.#returnToPrevious(pending, "failed", "db-backup-failed"); return; @@ -572,14 +533,14 @@ export class Launcher { return; } this.#timer = undefined; - // Old targets without the preflight field retain the original stopped-copy - // path. New targets copy online while the active child continues serving. - const needsBackup = await targetNeedsDatabaseBackup(this.#baseDir, pending); + // Copy online while the active child continues serving. Every update keeps a backup: + // the trial can write payloads this version cannot read without any migration. A + // database the online copy cannot read takes the stopped copy in #startTrial. const backupDir = databaseBackupDir(this.#baseDir, pending.id); const stagingDir = `${backupDir}.staging`; let prepared: Awaited> | undefined; try { - if (needsBackup === true && !(await pathExists(backupDir))) { + if (!(await pathExists(backupDir))) { prepared = await prepareServiceDatabaseBackup(pending.dbPath, stagingDir); } } catch { @@ -607,7 +568,7 @@ export class Launcher { } finally { prepared?.close(); } - await this.#startTrial(pending, needsBackup); + await this.#startTrial(pending); } async #handlePrepared(child: ManagedChild, updateId: string): Promise { From 505179967f7a2d5897899187147670c9a5bfb00b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:35:24 +0000 Subject: [PATCH 063/241] fix(contracts): a provider delta an older client cannot read drops that provider Config snapshots read providers through a forward-compatible array, so an older client drops a provider whose status, auth or model value it does not know. Provider deltas decoded each patch strictly, so the same value failed the whole config stream and reconnected on every provider tick. A patch this build cannot read now decodes to nothing and the client drops that provider; a patch for a provider it already dropped and unknown ids in the provider order are left out instead of failing the config. Applying a delta also keeps the objects of fields and providers it left alone, so settings and keybindings readers no longer re-render on every provider health or quota tick. --- .../client-runtime/src/rpc/session.test.ts | 32 +++++++++++++- packages/contracts/src/server.ts | 16 ++++--- packages/shared/src/serverConfigDelta.ts | 44 ++++++++++++++----- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index a597ba296bc44..9a34309dfabe4 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -314,7 +314,7 @@ describe("RpcSessionFactory", () => { providers: [other, { ...withoutName, checkedAt: "2026-08-28T00:00:00.000Z" }], }; const delta = diffServerConfig(previous, next, "changed"); - expect(delta.providerChanges[0]?.changes.models).toBeUndefined(); + expect(delta.providerChanges[0]?.changes?.models).toBeUndefined(); const wire = decodeServerConfigStreamEvent(encodeServerConfigStreamEvent(delta)); expect(wire.type).toBe("configDelta"); if (wire.type !== "configDelta") return; @@ -326,6 +326,36 @@ describe("RpcSessionFactory", () => { expect(applyServerConfigDelta(removed, diffServerConfig(removed, previous, "added"))).toEqual( previous, ); + + // A newer server's provider value this client cannot read drops that provider, as a + // snapshot does, instead of failing the stream. + const future = encodeServerConfigStreamEvent( + diffServerConfig( + previous, + { ...previous, providers: [provider, { ...other, version: "2" }] }, + "f", + ), + ); + const futurePatch = ( + future as unknown as { providerChanges: Array<{ changes: Record }> } + ).providerChanges[0]!; + futurePatch.changes.status = "a-later-state"; + const futureWire = decodeServerConfigStreamEvent(future); + if (futureWire.type !== "configDelta") throw new Error("expected a config delta"); + expect(applyServerConfigDelta(previous, futureWire).providers).toEqual([provider]); + // So is a later patch for it, and its id in the provider order. + const ghost = ProviderInstanceId.make("ghost"); + const late = applyServerConfigDelta(previous, { + ...diffServerConfig(previous, previous, "late"), + providerChanges: [{ instanceId: ghost, changes: { checkedAt: "x" }, removed: [] }], + providerOrder: [ghost, other.instanceId, provider.instanceId], + }); + expect(late.providers).toEqual([other, provider]); + // Fields and providers a delta leaves alone keep their identity. + expect(late.settings).toBe(previous.settings); + expect(late.providers[0]).toBe(other); + const unchangedOrder = applyServerConfigDelta(previous, diffServerConfig(previous, next, "x")); + expect(unchangedOrder.settings).toBe(previous.settings); }); it.effect("deduplicates discovery calls and invalidates on explicit refresh", () => diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 990016778fac7..2c261fb103fd0 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -869,12 +869,16 @@ export const ServerConfigStreamEvent = Schema.Union([ providerChanges: Schema.Array( Schema.Struct({ instanceId: ProviderInstanceId, - // Snapshot defaults must not turn absent patch fields into clears. - changes: Schema.Struct({ - ...ServerProvider.mapFields(Struct.map(Schema.optionalKey)).fields, - slashCommands: Schema.optionalKey(Schema.Array(ServerProviderSlashCommand)), - skills: Schema.optionalKey(Schema.Array(ServerProviderSkill)), - }), + // Snapshot defaults must not turn absent patch fields into clears. A patch with a + // value this build cannot read decodes to undefined, and the client drops that + // provider, as the snapshot's forward-compatible provider array does. + changes: Schema.UndefinedOr( + Schema.Struct({ + ...ServerProvider.mapFields(Struct.map(Schema.optionalKey)).fields, + slashCommands: Schema.optionalKey(Schema.Array(ServerProviderSlashCommand)), + skills: Schema.optionalKey(Schema.Array(ServerProviderSkill)), + }), + ).pipe(Schema.catchDecoding(() => Effect.succeedSome(undefined))), removed: Schema.Array(Schema.String), }), ), diff --git a/packages/shared/src/serverConfigDelta.ts b/packages/shared/src/serverConfigDelta.ts index 7da1deddedf96..5d614f5554411 100644 --- a/packages/shared/src/serverConfigDelta.ts +++ b/packages/shared/src/serverConfigDelta.ts @@ -1,8 +1,9 @@ -import { ServerConfig, type ServerConfigStreamEvent } from "@t3tools/contracts"; +import { ServerConfig, ServerProvider, type ServerConfigStreamEvent } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; type ConfigDelta = Extract; const decodeConfig = Schema.decodeUnknownSync(Schema.toType(ServerConfig)); +const isServerProvider = Schema.is(ServerProvider); function changedFields(previous: T, next: T) { const changes: Partial = {}; @@ -56,17 +57,40 @@ export function applyServerConfigDelta(current: ServerConfig, event: ConfigDelta for (const key of event.removed) Reflect.deleteProperty(config, key); const providers = new Map(current.providers.map((provider) => [provider.instanceId, provider])); for (const patch of event.providerChanges) { + // A patch this build cannot read leaves its provider out, as a snapshot's + // forward-compatible provider array leaves out a provider it cannot read. + if (patch.changes === undefined) { + providers.delete(patch.instanceId); + continue; + } const provider = { ...providers.get(patch.instanceId), ...patch.changes }; for (const key of patch.removed) Reflect.deleteProperty(provider, key); - // Decoded together below so the config's forward-compatible provider array - // retains its existing behavior for unknown provider states. providers.set(patch.instanceId, provider as ServerConfig["providers"][number]); } - return decodeConfig({ - ...config, - providers: - event.providerOrder === undefined - ? [...providers.values()] - : event.providerOrder.map((id) => providers.get(id)), - }); + const ordered = + event.providerOrder === undefined + ? [...providers.values()] + : event.providerOrder.flatMap((id) => providers.get(id) ?? []); + // A patch for a provider this client already left out is partial; leave it out too. + const decoded = decodeConfig({ ...config, providers: ordered.filter(isServerProvider) }); + // Decoding copies every field. Keep the objects this delta left alone, so readers of + // unchanged settings, keybindings or providers keep their identity. + const changed = new Set([...Object.keys(event.changes), ...event.removed]); + const kept: Record = { ...decoded }; + for (const key of Object.keys(decoded)) { + if (key !== "providers" && !changed.has(key) && key in current) { + kept[key] = current[key as keyof ServerConfig]; + } + } + const patched = new Set(event.providerChanges.map((patch) => patch.instanceId)); + const previous = new Map(current.providers.map((provider) => [provider.instanceId, provider])); + const keptProviders = decoded.providers.map((provider) => + patched.has(provider.instanceId) ? provider : (previous.get(provider.instanceId) ?? provider), + ); + kept.providers = + keptProviders.length === current.providers.length && + keptProviders.every((provider, index) => provider === current.providers[index]) + ? current.providers + : keptProviders; + return kept as ServerConfig; } From 152f3273566421554ce3cb53fccff23babee59ab Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:37:20 +0000 Subject: [PATCH 064/241] fix(server): compact shells keep whole pull request stacks The compact shell kept only the stack layers the same thread links, and the web's saved stack (PR detail, stack popover) is built from shell links, so a five-layer stack linked through one PR showed one layer until a refresh, and none when the refresh failed. Stacks stay whole now. The read payload and the shell wire already send each distinct stack once per thread, so on the live PR data this adds 130 KB of stack JSON across 1,401 threads (686 KB whole vs 556 KB trimmed). --- .../orchestration-v2/ProjectionStore.test.ts | 8 +++++- .../src/orchestration-v2/ThreadReadPayload.ts | 28 ++++--------------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index cce5a92318868..f54b8b5647c68 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -4991,7 +4991,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { number: 1, url: "https://github.com/pingdotgg/t3code/stack/1", base: "main", - layers: [7, 8, 9].map((number) => ({ + // The thread links 7-9; 6 and 10 are stack layers it does not link. + layers: [6, 7, 8, 9, 10].map((number) => ({ number, headBranch: `branch-${number}`, state: "open" as const, @@ -5010,6 +5011,11 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const compact = compactThreadPullRequests(rich); assert.isUndefined(compact.pullRequests?.[0]?.snapshot?.additions); assert.equal(rich.pullRequests[0]?.snapshot?.additions, 23); + // Clients show the saved stack before it refreshes, so the compact shell keeps it whole. + assert.deepEqual( + compact.pullRequests?.map((link) => link.stack), + rich.pullRequests.map((link) => link.stack), + ); assert.deepEqual( resolveThreadPullRequestBadge(compact.pullRequests), resolveThreadPullRequestBadge(rich.pullRequests), diff --git a/apps/server/src/orchestration-v2/ThreadReadPayload.ts b/apps/server/src/orchestration-v2/ThreadReadPayload.ts index 011eb1581e080..4ef2454f09e87 100644 --- a/apps/server/src/orchestration-v2/ThreadReadPayload.ts +++ b/apps/server/src/orchestration-v2/ThreadReadPayload.ts @@ -6,26 +6,17 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; -/** Keep shell badges, search, chain order and settlement dates; PR views read canonical snapshots. */ +/** + * Keep shell badges, search, chain order and settlement dates; PR views read canonical snapshots. + * Stacks stay whole: clients show a stack's saved layers before it refreshes, and the read + * payload and the shell wire send each distinct stack once per thread. + */ export const compactThreadPullRequests = ( thread: OrchestrationV2AppThread, ): OrchestrationV2AppThread => { const links = thread.pullRequests; if (links === undefined) return thread; - const stackKey = (link: (typeof links)[number]) => { - const { host, repository } = normalizeThreadPullRequestKey(link); - return JSON.stringify([host, repository, link.stack?.id]); - }; - const members = new Map>(); - for (const link of links) { - if (link.stack === null) continue; - const key = stackKey(link); - const numbers = members.get(key) ?? new Set(); - numbers.add(link.number); - members.set(key, numbers); - } return { ...thread, pullRequests: links.map((link) => ({ @@ -44,15 +35,6 @@ export const compactThreadPullRequests = ( ...(link.snapshot.mergedAt == null ? {} : { mergedAt: link.snapshot.mergedAt }), ...(link.snapshot.closedAt == null ? {} : { closedAt: link.snapshot.closedAt }), }, - stack: - link.stack === null - ? null - : { - ...link.stack, - layers: link.stack.layers.filter((layer) => - members.get(stackKey(link))?.has(layer.number), - ), - }, })), }; }; From f1c20edb504856acebbb2ae37d090080679546bc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:44:58 +0000 Subject: [PATCH 065/241] fix(server): source control discovery reruns after a credential change Discovery answered from a one-minute cache that only refresh bypassed, and older clients never send refresh, so saving a GitHub token or signing in showed the old auth state for up to a minute. A refresh also joined a run already in flight and returned its pre-change result, which every caller then saw for the next minute. A settings change (credentials are saved through settings) now drops the cached run, and a refresh always starts a new run that later callers share. --- .../SourceControlDiscovery.test.ts | 20 +++++++ .../sourceControl/SourceControlDiscovery.ts | 59 +++++++++++++++---- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 21d1934a6beed..7182de02029e4 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -13,6 +13,8 @@ import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; import { VcsProcessSpawnError } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -129,6 +131,12 @@ it.effect("reports implemented tools separately from locally available executabl }, } satisfies Partial; const layerTest = SourceControlDiscovery.layer.pipe( + Layer.provideMerge( + ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(SqlitePersistence.layerMemory), + ), + ), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-", @@ -165,6 +173,17 @@ it.effect("reports implemented tools separately from locally available executabl yield* TestClock.adjust("1 minute"); yield* discovery.discover; assert.isAbove(probeCount, refreshedProbeCount); + // Credentials are saved through settings, so a settings change starts a new run. + const cachedProbeCount = probeCount; + yield* discovery.discover; + assert.strictEqual(probeCount, cachedProbeCount); + yield* (yield* ServerSettings.ServerSettingsService).updateSettings({ + responseStreamingMode: "turn", + }); + // Let the change subscription see the published settings. + yield* Effect.yieldNow; + yield* discovery.discover; + assert.isAbove(probeCount, cachedProbeCount); assert.deepStrictEqual( result.versionControlSystems.map((item) => ({ @@ -289,6 +308,7 @@ Logged in to gitlab.com as gitlab-user }, } satisfies Partial; const layerTest = SourceControlDiscovery.layer.pipe( + Layer.provide(ServerSettings.ServerSettingsService.layerTest()), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-", diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 6eec5f37c2f80..f517c5ded1703 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -3,12 +3,16 @@ import { type VcsDiscoveryItem, type VcsDriverKind, } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { detailFromCause, firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -38,6 +42,8 @@ interface DiscoveryProbeResult { readonly detail: Option.Option; } +const DISCOVERY_TTL_MS = 60_000; + const VCS_PROBES: ReadonlyArray = [ { kind: "git", @@ -130,20 +136,47 @@ export const make = Effect.gen(function* () { ); }; - const [discover, invalidate] = yield* Effect.cachedInvalidateWithTTL( - Effect.all({ - versionControlSystems: Effect.all( - VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, - { concurrency: "unbounded" }, - ), - sourceControlProviders: sourceControlProviders.discover, - }), - "1 minute", - ); - return SourceControlDiscovery.of({ - discover, - refresh: invalidate.pipe(Effect.andThen(discover)), + const discoverNow = Effect.all({ + versionControlSystems: Effect.all( + VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, + { concurrency: "unbounded" }, + ), + sourceControlProviders: sourceControlProviders.discover, }); + // Discovery spawns a process per tool, so callers within a minute share one run. A refresh + // or a settings change (credentials are saved through settings) starts a new run for every + // later caller, rather than joining one that began before the change. + const scope = yield* Effect.scope; + let latest: + | { + readonly startedAt: number; + readonly result: Deferred.Deferred; + } + | undefined; + const run = (fresh: boolean) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const { result, started } = yield* Effect.sync(() => { + if (!fresh && latest !== undefined && now - latest.startedAt < DISCOVERY_TTL_MS) { + return { result: latest.result, started: false }; + } + const result = Deferred.makeUnsafe(); + latest = { startedAt: now, result }; + return { result, started: true }; + }); + if (started) yield* discoverNow.pipe(Deferred.into(result), Effect.forkIn(scope)); + return yield* Deferred.await(result); + }); + const settingsChanges = yield* (yield* ServerSettings.ServerSettingsService).subscribeChanges; + yield* settingsChanges.pipe( + Stream.runForEach(() => + Effect.sync(() => { + latest = undefined; + }), + ), + Effect.forkIn(scope), + ); + return SourceControlDiscovery.of({ discover: run(false), refresh: run(true) }); }); export const layer = Layer.effect(SourceControlDiscovery, make); From e2b0a03b5f49a17534b00345d4db8dd66e18213f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:49:27 +0000 Subject: [PATCH 066/241] fix(cli): the compile cache skips AppImage and server helpers and keeps one version Under AppImage the bundle mounts at a new path every launch and Node keys cache entries by path, so the backend and the CLI shim wrote a fresh, never-hit copy of the server bundle's cache into ~/.t3 on every launch. The desktop already skips it there; the CLI now does too. Helpers the server starts (acp-mcp-bridge, acp-mcp-call, __claude-history) get no --base-dir, so they cached under ~/.t3 for a server with another home, and `sudo t3 browser setup` could leave a root-owned cache there. They run uncached, like the spawn host. The cache now lives in a directory per version, and enabling it removes other versions' directories, whose entries never hit again. --- apps/server/src/bin.ts | 13 +++++-- apps/server/src/cliCompileCache.ts | 21 +++++++++-- apps/server/src/compileCache.test.ts | 54 ++++++++++++++++++++++++---- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 10e0e588c2c65..58ccc79c6ef9b 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -21,12 +21,21 @@ if ( }) ) { const command = process.argv[2]; - // The spawn host loads only Node builtins and has no --base-dir to place a cache under. - if (!(process.argv.length === 3 && command === "--version") && command !== "__spawn-host") { + // The server starts these helpers without its --base-dir, so they would cache under + // ~/.t3 for a server with another home; `browser setup` runs under sudo, which could + // leave a root-owned cache there. + const uncached = + command === "__spawn-host" || + command === "__claude-history" || + command === "acp-mcp-bridge" || + command === "acp-mcp-call" || + command === "browser"; + if (!(process.argv.length === 3 && command === "--version") && !uncached) { enableCliCompileCache({ bundled: NodeSea.isSea() || import.meta.url.endsWith(".mjs"), argv: process.argv.slice(2), environment: process.env, + version: packageJson.version, }); } if (process.argv.length === 3 && command === "--version") { diff --git a/apps/server/src/cliCompileCache.ts b/apps/server/src/cliCompileCache.ts index d0d8ad3100184..ec1edf95fcb7e 100644 --- a/apps/server/src/cliCompileCache.ts +++ b/apps/server/src/cliCompileCache.ts @@ -1,16 +1,25 @@ // @effect-diagnostics nodeBuiltinImport:off -- Runs before loading the Effect CLI or server graph. +import * as NodeFS from "node:fs"; import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -/** Source dev stays uncached; bundled and SEA dependencies persist under the selected T3 home. */ +/** + * Source dev stays uncached; bundled and SEA dependencies persist under the + * selected T3 home, in one directory per version. + */ export function enableCliCompileCache(input: { readonly bundled: boolean; readonly argv: readonly string[]; readonly environment: NodeJS.ProcessEnv; + readonly version: string; }) { if ( !input.bundled || + // AppImage mounts the app at a new /tmp/.mount_* path every launch and Node + // keys entries by path, so they would never hit, only pile up (the desktop's + // compileCache.ts skips it for the same reason). + input.environment.APPIMAGE || input.environment.NODE_DISABLE_COMPILE_CACHE === "1" || input.environment.NODE_COMPILE_CACHE ) @@ -31,8 +40,16 @@ export function enableCliCompileCache(input: { : base.startsWith("~/") || base.startsWith("~\\") ? NodePath.join(home, base.slice(2)) : NodePath.resolve(base); + const root = NodePath.join(directory, "compile-cache"); // Cache failures never prevent CLI or server startup, including a read-only home. try { - NodeModule.enableCompileCache(NodePath.join(directory, "compile-cache")); + const { status } = NodeModule.enableCompileCache(NodePath.join(root, input.version)); + if (status !== NodeModule.constants.compileCacheStatus.ENABLED) return; + // Bundle chunk names change with their contents, so other versions' entries + // never hit again. Only the running version's directory stays. + for (const entry of NodeFS.readdirSync(root)) { + if (entry === input.version) continue; + NodeFS.rm(NodePath.join(root, entry), { recursive: true, force: true }, () => {}); + } } catch {} } diff --git a/apps/server/src/compileCache.test.ts b/apps/server/src/compileCache.test.ts index 10a544786f450..d8a4446421ee9 100644 --- a/apps/server/src/compileCache.test.ts +++ b/apps/server/src/compileCache.test.ts @@ -52,9 +52,14 @@ process.kill(process.pid, "SIGKILL");`, }, ); -it.each([false, true])( - "selects the CLI cache under the explicit T3 home (disabled: %s)", - async (disabled) => { +it.each([ + { disabledBy: undefined }, + { disabledBy: "NODE_DISABLE_COMPILE_CACHE" }, + { disabledBy: "APPIMAGE" }, +] as const)( + "selects the CLI cache under the explicit T3 home (disabled by: $disabledBy)", + async ({ disabledBy }) => { + const disabled = disabledBy !== undefined; const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-cli-cache-")); try { const child = NodeChildProcess.spawnSync( @@ -64,7 +69,7 @@ it.each([false, true])( "--eval", ` const { enableCliCompileCache } = await import(${JSON.stringify(new URL("./cliCompileCache.ts", import.meta.url).href)}); -enableCliCompileCache({ bundled: true, argv: ["serve", "--base-dir", ${JSON.stringify(directory)}], environment: process.env }); +enableCliCompileCache({ bundled: true, argv: ["serve", "--base-dir", ${JSON.stringify(directory)}], environment: process.env, version: "1.2.3" }); await import(${JSON.stringify(import.meta.resolve("effect/Effect"))}); const { flushCompileCache } = await import(${JSON.stringify(new URL("./compileCache.ts", import.meta.url).href)}); const Effect = await import(${JSON.stringify(import.meta.resolve("effect/Effect"))}); @@ -76,20 +81,23 @@ process.kill(process.pid, "SIGKILL");`, env: { ...process.env, NODE_COMPILE_CACHE: undefined, - NODE_DISABLE_COMPILE_CACHE: disabled ? "1" : undefined, + NODE_DISABLE_COMPILE_CACHE: + disabledBy === "NODE_DISABLE_COMPILE_CACHE" ? "1" : undefined, + APPIMAGE: disabledBy === "APPIMAGE" ? "/opt/T3-Code.AppImage" : undefined, }, }, ); assert.equal(child.error, undefined); assert.equal(child.stderr, ""); - const entries = await NodeFSP.readdir(NodePath.join(directory, "compile-cache"), { + const cacheDirectory = NodePath.join(directory, "compile-cache", "1.2.3"); + const entries = await NodeFSP.readdir(cacheDirectory, { recursive: true, }).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return []; throw error; }); const files = await Promise.all( - entries.map((entry) => NodeFSP.stat(NodePath.join(directory, "compile-cache", entry))), + entries.map((entry) => NodeFSP.stat(NodePath.join(cacheDirectory, entry))), ); assert.equal( files.some((entry) => entry.isFile()), @@ -101,6 +109,38 @@ process.kill(process.pid, "SIGKILL");`, }, ); +it("keeps only the running version's CLI cache directory", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-cli-cache-")); + try { + const root = NodePath.join(directory, "compile-cache"); + await NodeFSP.mkdir(NodePath.join(root, "1.2.2", "node"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(root, "1.2.2", "node", "entry"), "stale"); + const child = NodeChildProcess.spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + ` +const { enableCliCompileCache } = await import(${JSON.stringify(new URL("./cliCompileCache.ts", import.meta.url).href)}); +enableCliCompileCache({ bundled: true, argv: ["serve", "--base-dir", ${JSON.stringify(directory)}], environment: process.env, version: "1.2.3" });`, + ], + { + encoding: "utf8", + env: { + ...process.env, + NODE_COMPILE_CACHE: undefined, + NODE_DISABLE_COMPILE_CACHE: undefined, + }, + }, + ); + assert.equal(child.error, undefined); + assert.equal(child.stderr, ""); + assert.deepEqual(await NodeFSP.readdir(root), ["1.2.3"]); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } +}); + it("preserves the version output through the thin CLI entry", async () => { const packageJson = JSON.parse( await NodeFSP.readFile(new URL("../package.json", import.meta.url), "utf8"), From 39408ff1f0900b1a48b5f822435b69c978c8c8ad Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:51:56 +0000 Subject: [PATCH 067/241] fix(server): the browser sandbox warning logs at startup again Loading the server browser on first use moved its AppArmor sandbox check into the lazily built module, so a headless server on Ubuntu 24.04 said nothing at boot and the first preview tab just failed. The check runs from the always-built service again, forked, and reads only two files. --- apps/server/src/preview/ServerBrowser.ts | 16 ++++++++++++++++ apps/server/src/preview/ServerBrowserLive.ts | 9 --------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 40cbcd29824a0..029a166496be9 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -15,6 +15,9 @@ import * as Stream from "effect/Stream"; import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as PreviewManager from "./Manager.ts"; +import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; +import { resolveRootCliCommand } from "../cli/invocation.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { cacheInOwnerScope } from "@t3tools/shared/lazyService"; export class ServerBrowserTabNotFoundError extends Schema.TaggedError()( "ServerBrowserTabNotFoundError", @@ -194,6 +197,19 @@ const make = Effect.gen(function* () { ), Effect.forkScoped, ); + // Whoever runs the server learns the fix before anyone opens a tab, without loading the browser. + yield* Effect.gen(function* () { + if ( + PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment) || + !(yield* PreviewBrowserHost.sandboxBlocked) + ) { + return; + } + const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); + yield* Effect.logWarning( + `This host blocks the sandbox T3's browser runs in, so browser tabs and HTML previews will not start. Run \`${setupCommand}\` once to allow it.`, + ); + }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); return ServerBrowser.of({ attachViewer: (input) => live.pipe(Effect.flatMap((value) => value.service.attachViewer(input))), diff --git a/apps/server/src/preview/ServerBrowserLive.ts b/apps/server/src/preview/ServerBrowserLive.ts index 31be5331721a3..dbe3651467d64 100644 --- a/apps/server/src/preview/ServerBrowserLive.ts +++ b/apps/server/src/preview/ServerBrowserLive.ts @@ -2243,15 +2243,6 @@ export const make = Effect.gen(function* () { } satisfies ServerBrowserViewer; }); - // Whoever runs the server learns the fix before anyone opens a tab. - if ( - !PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment) && - (yield* PreviewBrowserHost.sandboxBlocked) - ) { - yield* Effect.logWarning( - `This host blocks the sandbox T3's browser runs in, so browser tabs and HTML previews will not start. Run \`${setupCommand}\` once to allow it.`, - ); - } // The desktop took its page back (closed, swapped, crashed, or devtools opened). // The session stays; the next viewer or agent reconnects when it re-attaches. yield* desktopChannel.detached.pipe( From e23357eb89c544953a8820eb6edf7e7d1de564cc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:42:43 +0000 Subject: [PATCH 068/241] fix(web): a thread warming up out of sight no longer answers shortcuts The thread a first open left gets a kept view that lays out invisibly so a revisit only reveals it. Its effects are live while it lays out, so its window keydown and paste listeners ran next to the shown view's: a shortcut could act on the hidden thread or on both. The view now renders hidden first (React prerenders it at idle priority, without effects) and lays out at the next quiet, idle moment. Any keydown, pointerdown, wheel or paste while it lays out hides it again synchronously, before the event reaches its listeners, and it lays out at the next quiet moment. The lifetimes of kept views move into a small holder so the route view no longer writes refs during render. --- apps/web/src/components/ThreadRouteView.tsx | 133 ++++++++++++-------- apps/web/src/components/keptView.tsx | 31 ++++- 2 files changed, 109 insertions(+), 55 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index a6835e0ca983b..ef23fa5937fbf 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -5,7 +5,6 @@ import { Activity, memo, startTransition, - useCallback, useDeferredValue, useEffect, useEffectEvent, @@ -14,9 +13,10 @@ import { useRef, useState, } from "react"; +import { flushSync } from "react-dom"; import ChatView from "./ChatView"; -import { createKeptViewLifetime, KeptViewFrame, KeptViewLifetimeContext } from "./keptView"; +import { createKeptViewLifetimes, KeptViewFrame, KeptViewLifetimeContext } from "./keptView"; import { resolveDraftPromotionNavigationTarget, threadHasStarted } from "./ChatView.logic"; import { waitForDraftHeroTransition } from "./chat/draftHeroTransition"; import { SidebarInset } from "./ui/sidebar"; @@ -62,17 +62,57 @@ let keptThreadKeys: ReadonlySet = new Set(); const WARM_UP_QUIET_MS = 1_000; let lastUserInputAt = 0; let trackingUserInput = false; +/** + * Hides the view that is laying out out of sight. Its effects are live then, + * so its window shortcut and paste listeners would answer input along with + * the shown view's. + */ +let interruptWarmUp: (() => void) | null = null; function trackUserInput() { if (trackingUserInput || typeof window === "undefined") return; trackingUserInput = true; + // Added before any warming view's listeners, so it runs first and the + // hidden view's listeners are already removed when the event reaches them. const record = () => { lastUserInputAt = performance.now(); + interruptWarmUp?.(); }; - for (const type of ["keydown", "pointerdown", "wheel"] as const) { + for (const type of ["keydown", "pointerdown", "wheel", "paste"] as const) { window.addEventListener(type, record, { capture: true, passive: true }); } } +/** + * Runs `callback` once the user has left the app alone for WARM_UP_QUIET_MS + * and the browser is idle. Returns a function that cancels it. + */ +function whenQuietAndIdle(callback: () => void, initialDelayMs: number): () => void { + let timeout: ReturnType | undefined; + let idle: number | undefined; + const attempt = () => { + const quietFor = performance.now() - lastUserInputAt; + if (quietFor < WARM_UP_QUIET_MS) { + timeout = setTimeout(attempt, WARM_UP_QUIET_MS - quietFor); + } else if (typeof requestIdleCallback === "function") { + idle = requestIdleCallback( + () => { + idle = undefined; + if (performance.now() - lastUserInputAt < WARM_UP_QUIET_MS) attempt(); + else callback(); + }, + { timeout: 2_000 }, + ); + } else { + callback(); + } + }; + timeout = setTimeout(attempt, initialDelayMs); + return () => { + clearTimeout(timeout); + if (idle !== undefined) cancelIdleCallback(idle); + }; +} + let bootChatViewDeferred = false; // How long the chat view waits for the session before rendering from the @@ -334,77 +374,67 @@ export const ThreadRouteView = memo(function ThreadRouteView({ } // A first open took over the shown view. The thread it showed gets a view // of its own again, which warms up out of sight (see KeptViewFrame) so a - // revisit only reveals it. Mounting it is a long task, so it waits until - // the shown thread has loaded and the user has left the app alone. + // revisit only reveals it. Mounting it is long work, so it waits until the + // shown thread has loaded and the user has left the app alone. The view + // first renders hidden, which React does at idle priority in small slices; + // only laying it out (its effects and measurements) is one task, and that + // waits for the next quiet, idle moment. const shownThreadStatus = useThreadStatus(target.kind === "server" ? target.threadRef : null); const shownThreadLoaded = target.kind !== "server" || shownThreadStatus === "live" || shownThreadStatus === "deleted"; useEffect(trackUserInput, []); useEffect(() => { if (releasedView === null || !shownThreadLoaded) return; - let timeout: ReturnType | undefined; - let idle: number | undefined; - const keep = () => - startTransition(() => { - setKeptViews((current) => keepReleasedThreadView(current, releasedView)); - setReleasedView(null); - }); - const attempt = () => { - const quietFor = performance.now() - lastUserInputAt; - if (quietFor < WARM_UP_QUIET_MS) { - timeout = setTimeout(attempt, WARM_UP_QUIET_MS - quietFor); - } else if (typeof requestIdleCallback === "function") { - idle = requestIdleCallback(keep, { timeout: 2_000 }); - } else { - keep(); - } - }; - timeout = setTimeout(attempt, WARM_UP_QUIET_MS); + return whenQuietAndIdle( + () => + startTransition(() => { + setKeptViews((current) => keepReleasedThreadView(current, releasedView)); + setReleasedView(null); + }), + WARM_UP_QUIET_MS, + ); + }, [releasedView, shownThreadLoaded]); + const [laidOutWarmKey, setLaidOutWarmKey] = useState(null); + const warmingKey = keptViews.find((view) => view.warming === true)?.key ?? null; + const layingOutKey = warmingKey !== null && warmingKey === laidOutWarmKey ? warmingKey : null; + useEffect(() => { + if (warmingKey === null || layingOutKey !== null) return; + return whenQuietAndIdle(() => startTransition(() => setLaidOutWarmKey(warmingKey)), 0); + }, [layingOutKey, warmingKey]); + // Input while the view lays out hides it again; it lays out at the next + // quiet moment. + useLayoutEffect(() => { + if (layingOutKey === null) return; + interruptWarmUp = () => flushSync(() => setLaidOutWarmKey(null)); return () => { - clearTimeout(timeout); - if (idle !== undefined) cancelIdleCallback(idle); + interruptWarmUp = null; }; - }, [releasedView, shownThreadLoaded]); - const finishWarming = useCallback((viewKey: string) => { + }, [layingOutKey]); + const finishWarming = (viewKey: string) => { setKeptViews((current) => finishWarmingThreadView(current, viewKey)); - }, []); + setLaidOutWarmKey(null); + }; useLayoutEffect(() => { keptThreadKeys = new Set( keptViews.flatMap((view) => (view.draftId === null ? [view.threadKey] : [])), ); }, [keptViews]); // One lifetime per kept view, released once the view is gone for good. - const lifetimesRef = useRef(new Map>()); + const [lifetimes] = useState(createKeptViewLifetimes); const insetRef = useRef(null); - const lifetimeFor = (key: string) => { - let lifetime = lifetimesRef.current.get(key); - if (lifetime === undefined) { - lifetime = createKeptViewLifetime(); - lifetimesRef.current.set(key, lifetime); - } - return lifetime; - }; useLayoutEffect(() => { - const keys = new Set(keptViews.map((view) => view.key)); - for (const [key, lifetime] of lifetimesRef.current) { - if (keys.has(key)) continue; - lifetimesRef.current.delete(key); - lifetime.dispose(); - } - }, [keptViews]); + lifetimes.retain(new Set(keptViews.map((view) => view.key))); + }, [keptViews, lifetimes]); useEffect(() => { - const lifetimes = lifetimesRef.current; const inset = insetRef.current; return () => { // Hidden behind a full page, the views stay in the document and keep their // lifetimes; only an unmount releases them. setTimeout(() => { - if (inset?.isConnected) return; - for (const lifetime of lifetimes.values()) lifetime.dispose(); - lifetimes.clear(); + if (!inset?.isConnected) lifetimes.retain(new Set()); }, 0); }; - }, []); + }, [lifetimes]); const activeViewKey = activeView === null ? null @@ -423,6 +453,7 @@ export const ThreadRouteView = memo(function ThreadRouteView({ if (!media.paused) media.pause(); } }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each change of the shown view pauses the one it hid. }, [activeViewKey]); return ( @@ -434,14 +465,14 @@ export const ThreadRouteView = memo(function ThreadRouteView({ nextKeptViews.map((view) => ( - + {view.draftId === null ? ( >(); + return { + get(key: string): KeptViewLifetime { + let lifetime = lifetimes.get(key); + if (lifetime === undefined) { + lifetime = createKeptViewLifetime(); + lifetimes.set(key, lifetime); + } + return lifetime; + }, + /** Releases every view whose key is not in `keys`. */ + retain(keys: ReadonlySet) { + for (const [key, lifetime] of lifetimes) { + if (keys.has(key)) continue; + lifetimes.delete(key); + lifetime.dispose(); + } + }, + }; +} + type EditorCallbackKey = | "onBeforeCreate" | "onCreate" @@ -261,19 +285,18 @@ export function KeptViewFrame({ onWarm: (viewKey: string) => void; children: ReactNode; }) { - const onWarmRef = useRef(onWarm); - onWarmRef.current = onWarm; + const warmed = useEffectEvent(() => onWarm(viewKey)); useLayoutEffect(() => { if (!warming) return; let second = 0; const first = requestAnimationFrame(() => { - second = requestAnimationFrame(() => onWarmRef.current(viewKey)); + second = requestAnimationFrame(warmed); }); return () => { cancelAnimationFrame(first); cancelAnimationFrame(second); }; - }, [viewKey, warming]); + }, [warming]); return (
Date: Sat, 10 Oct 2026 16:42:59 +0000 Subject: [PATCH 069/241] fix(web): undo in a thread never brings back another thread's prompt A first open switches the shown chat view to the new thread and keeps its composer editor, so the undo history still held the previous thread's edits and the store rewrite that loaded the new prompt. Undo in the new thread restored the old thread's text and saved it into the new draft. The editor now starts a new history when its thread changes. --- .../components/ComposerPromptEditorTiptap.tsx | 24 +++++++++++++------ apps/web/src/components/chat/ChatComposer.tsx | 1 + 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 23cfb0021a00f..4ae632db52a3d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -4,13 +4,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tip import StarterKit from "@tiptap/starter-kit"; import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; -import { - type EditorState, - NodeSelection, - Plugin, - PluginKey, - TextSelection, -} from "@tiptap/pm/state"; +import { EditorState, NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; import type { NodeType, ResolvedPos } from "@tiptap/pm/model"; import type { @@ -135,6 +129,11 @@ export interface ComposerPromptEditorHandle { export interface ComposerPromptEditorProps { value: string; cursor: number; + /** + * The thread the prompt belongs to. A kept chat view that switches threads + * keeps its editor, and a new scope starts a new undo history. + */ + historyScope?: string; /** * Render Markdown styling (bold, italic, code, strike, task checkboxes). * Off renders the same Tiptap engine as plain text: every marker stays a @@ -810,6 +809,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const { value, cursor, + historyScope, richTextEnabled, literalText = false, contextRecords, @@ -1587,6 +1587,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { value, ]); + // Runs after the effect above has loaded the new thread's prompt, so undo + // cannot bring back the previous thread's prompt. + const historyScopeRef = useRef(historyScope); + useLayoutEffect(() => { + if (!editor || historyScopeRef.current === historyScope) return; + historyScopeRef.current = historyScope; + const { doc, selection, plugins } = editor.state; + editor.view.updateState(EditorState.create({ doc, selection, plugins })); + }, [editor, historyScope]); + const focusAt = useCallback( (nextCursor: number) => { if (!editor) return; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 65840afa21984..fdf3415e1323b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -7489,6 +7489,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : prompt } cursor={composerCursor} + historyScope={`${routeThreadRef.environmentId}:${routeThreadRef.threadId}`} contextRecords={composerContextRecords} buildContextClipboardFragment={buildContextClipboardFragment} importContextFragment={importContextFragment} From 29375b07aaf223c49501542db1600ddb3e34297c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:42:59 +0000 Subject: [PATCH 070/241] fix(web): idle and hover preloads no longer reload the page on a failed chunk Any failed split-chunk fetch dispatches vite:preloadError, and the stale-deploy guard reloaded the page for it, including the palette's idle preload, router intent preloads and the pull request Code tab hover preload. A network blip during one of them reloaded the app under the user. Those preloads now run through preloadSpeculatively. While one is in flight the guard leaves a chunk failure to its caller; the preload swallows it, and the chunk's first real use fetches it again and gets the reload (split route components keep the failure and reload when rendered). --- .../web/src/components/CommandPaletteHost.tsx | 3 +- .../pullRequest/PullRequestDetailPanel.tsx | 3 +- apps/web/src/lib/chunkReloadGuard.test.ts | 28 ++++++++++++++- apps/web/src/lib/chunkReloadGuard.ts | 36 +++++++++++++++++++ apps/web/src/main.tsx | 6 ++-- apps/web/src/router.ts | 10 +++++- 6 files changed, 80 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/CommandPaletteHost.tsx b/apps/web/src/components/CommandPaletteHost.tsx index 964944f2348bf..71e991d6ad950 100644 --- a/apps/web/src/components/CommandPaletteHost.tsx +++ b/apps/web/src/components/CommandPaletteHost.tsx @@ -21,6 +21,7 @@ import { import { ComposerHandleContext } from "../composerHandleContext"; import { useTheme } from "../hooks/useTheme"; import { resolveShortcutCommand } from "../keybindings"; +import { preloadSpeculatively } from "../lib/chunkReloadGuard"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; @@ -134,7 +135,7 @@ export function CommandPaletteHost({ children }: { children: ReactNode }) { useEffect(() => { let idleCallback: number | null = null; const timeout = window.setTimeout(() => { - const preload = () => void loadCommandPalette().catch(() => undefined); + const preload = () => void preloadSpeculatively(loadCommandPalette); if (typeof window.requestIdleCallback === "function") { idleCallback = window.requestIdleCallback(preload); } else { diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 540e14f8a05fb..f62f7d346b438 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -66,6 +66,7 @@ import { primaryServerKeybindingsAtom } from "~/state/server"; import { usePullRequestDefaultMergeMethodResolver } from "./usePullRequestActions"; import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; +import { preloadSpeculatively } from "~/lib/chunkReloadGuard"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import type { ReviewCommentContext } from "~/reviewCommentContext"; @@ -2540,7 +2541,7 @@ export function PullRequestDetailPanel({ value={tab} onValueChange={setTab} onTabIntent={(item) => { - if (item === "code") void loadCodeTab(); + if (item === "code") void preloadSpeculatively(loadCodeTab); }} > {tab === "summary" ? ( diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 7816d77740ccf..0837f5bdb1a71 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { reloadOnceForChunkLoadError } from "./chunkReloadGuard"; +import { + isSpeculativeLoadPending, + preloadSpeculatively, + reloadOnceForChunkLoadError, +} from "./chunkReloadGuard"; function createStorageStub(): Storage { const store = new Map(); @@ -119,3 +123,25 @@ describe("reloadOnceForChunkLoadError", () => { expect(reload).not.toHaveBeenCalled(); }); }); + +describe("preloadSpeculatively", () => { + it("marks a failure reported during the preload as its own and swallows it", async () => { + let fail: (error: Error) => void = () => undefined; + const preload = preloadSpeculatively( + () => + new Promise((_, reject) => { + fail = reject; + }), + ); + + expect(isSpeculativeLoadPending()).toBe(true); + fail(new TypeError(failedImport("CommandPalette-1.js"))); + await expect(preload).resolves.toBeUndefined(); + expect(isSpeculativeLoadPending()).toBe(false); + }); + + it("stops claiming failures once the preload has loaded", async () => { + await expect(preloadSpeculatively(async () => "module")).resolves.toBe("module"); + expect(isSpeculativeLoadPending()).toBe(false); + }); +}); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index 10269acfaa15e..8c8538c147189 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -46,3 +46,39 @@ export function reloadOnceForChunkLoadError( reload(); return true; } + +let speculativeLoads = 0; + +/** + * Runs a load nothing waits on yet (an idle or hover preload). A chunk it + * fails to fetch, say on a network blip, does not reload the page: the first + * real use requests the chunk again and gets the reload if it still fails. + * Resolves to undefined on failure. + */ +export function preloadSpeculatively(load: () => Promise): Promise { + speculativeLoads += 1; + let pending = true; + const settle = () => { + if (!pending) return; + pending = false; + speculativeLoads -= 1; + }; + return new Promise((resolve) => resolve(load())).then( + (value) => { + settle(); + return value; + }, + () => { + settle(); + return undefined; + }, + ); +} + +/** + * Whether a speculative load is in flight. A chunk failure reported now may + * be its own, so the `vite:preloadError` listener leaves it to the caller. + */ +export function isSpeculativeLoadPending(): boolean { + return speculativeLoads > 0; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index f0eac0cc23e37..f507f96b3a94b 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -16,7 +16,7 @@ import { syncDocumentWindowControlsOverlayClass, } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; -import { reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +import { isSpeculativeLoadPending, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; import { restoreStackTraces } from "./lib/bootStackTraces"; // Every import above has evaluated; startup code runs with full stacks. @@ -51,9 +51,11 @@ const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string // entry URL names the build, and the error names the asset (except in // Safari), so each failed asset of each build gets one reload. Later // failures while that reload is pending keep their rejection and their reload. +// A failure while an idle or hover preload runs may be the preload's own; it +// keeps its rejection, and the chunk's first real use reloads. let reloadScheduled = false; window.addEventListener("vite:preloadError", (event) => { - if (reloadScheduled) return; + if (reloadScheduled || isSpeculativeLoadPending()) return; if (reloadOnceForChunkLoadError(import.meta.url, String(event.payload))) { reloadScheduled = true; event.preventDefault(); diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 67bf61c40a593..17977e391b9d5 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -1,9 +1,10 @@ import { createRouter, RouterHistory } from "@tanstack/react-router"; +import { preloadSpeculatively } from "./lib/chunkReloadGuard"; import { routeTree } from "./routeTree.gen"; export function getRouter(history: RouterHistory) { - return createRouter({ + const router = createRouter({ routeTree, history, context: {}, @@ -12,6 +13,13 @@ export function getRouter(history: RouterHistory) { // settings or pull-request navigation. defaultPreload: "intent", }); + // Intent preloads are speculative: a chunk they fail to fetch reloads the + // page only once a navigation renders it (the split route component keeps + // the failure and reloads on render). + const preloadRoute = router.preloadRoute; + router.preloadRoute = ((options: Parameters[0]) => + preloadSpeculatively(() => preloadRoute(options))) as typeof preloadRoute; + return router; } export type AppRouter = ReturnType; From 20761a8609ca35a8d3e43350eea04111bdf3902c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:43:17 +0000 Subject: [PATCH 071/241] fix(web): text typed while a search view loads becomes its query The palette kept keys typed while its chunk loaded only in command mode, so the first open of file search or content search (whose dialog loads its own chunk) dropped them. It also dropped Option and AltGr characters and lost a PR search's linked threads at the first typed key. Keys typed before the open view renders its input now become that view's query in every mode: a TypeAhead action keeps the mode and any linked threads, and file and content search start from it. Option on macOS and AltGr elsewhere count as text. --- .../components/CommandPalette.logic.test.ts | 27 +++++++++++ .../src/components/CommandPalette.logic.ts | 16 ++++++- apps/web/src/components/CommandPalette.tsx | 9 +++- .../web/src/components/CommandPaletteHost.tsx | 45 ++++++++++++++----- .../components/files/ProjectFilePicker.tsx | 12 ++++- .../search/ProjectContentSearchDialog.tsx | 6 ++- 6 files changed, 98 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 7c3868a994e11..11f3f0b6cc0b7 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -273,6 +273,33 @@ describe("reduceCommandPaletteUiState", () => { openIntent: null, }); }); + + it("keeps text typed while a view loads as that view's query", () => { + const contentOpen = reduceCommandPaletteUiState(closedState, { + _tag: "ToggleMode", + mode: "content", + }); + expect(reduceCommandPaletteUiState(contentOpen, { _tag: "TypeAhead", query: "fo" })).toEqual({ + open: true, + mode: "content", + openIntent: { kind: "search", query: "fo" }, + }); + + const linkedThreads = { environmentId: EnvironmentId.make("env"), threads: [] }; + const prSearch = reduceCommandPaletteUiState(closedState, { + _tag: "OpenSearch", + query: "#4", + linkedThreads, + }); + expect( + reduceCommandPaletteUiState(prSearch, { _tag: "TypeAhead", query: "#42" }).openIntent, + ).toEqual({ kind: "search", query: "#42", linkedThreads }); + + const addProject = reduceCommandPaletteUiState(closedState, { _tag: "OpenAddProject" }); + expect(reduceCommandPaletteUiState(addProject, { _tag: "TypeAhead", query: "a" })).toBe( + addProject, + ); + }); }); describe("enumerateCommandPaletteItems", () => { diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index c835f9105f285..915700770aa96 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -85,7 +85,9 @@ export type CommandPaletteUiAction = | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } | { readonly _tag: "OpenChangeTheme" } - | { readonly _tag: "ClearOpenIntent" }; + | { readonly _tag: "ClearOpenIntent" } + /** Text typed while the open view loads; it becomes that view's query. */ + | { readonly _tag: "TypeAhead"; readonly query: string }; export function reduceCommandPaletteUiState( state: CommandPaletteUiState, @@ -118,6 +120,18 @@ export function reduceCommandPaletteUiState( return { open: true, mode: "command", openIntent: { kind: "change-theme" } }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; + case "TypeAhead": { + const intent = state.openIntent; + if (!state.open || (intent !== null && intent.kind !== "search")) return state; + return { + ...state, + openIntent: { + kind: "search", + query: action.query, + ...(intent?.linkedThreads ? { linkedThreads: intent.linkedThreads } : {}), + }, + }; + } } } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 07d48ba01d343..db265f89d791b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -447,6 +447,11 @@ export function CommandPaletteDialog(props: { readonly clearOpenIntent: () => void; }) { const composerHandleRef = useComposerHandleContext(); + // File and content search start from text typed while they loaded. + const typedAheadQuery = + props.mode !== "command" && props.openIntent?.kind === "search" && props.openIntent.query + ? { initialQuery: props.openIntent.query } + : {}; return ( {props.mode === "files" ? ( - + ) : props.mode === "content" ? ( - + ) : ( >; +/** Whether the open view has rendered its text input, which then takes the keys. */ +function paletteTextInputMounted(): boolean { + const palette = document.getElementById(COMMAND_PALETTE_ELEMENT_ID); + return palette?.querySelector("input, textarea, [contenteditable='true']") != null; +} + +/** + * The text a key types, or null for shortcuts and keys that type nothing. + * Option on macOS and AltGr elsewhere type characters. + */ +function typedText(event: globalThis.KeyboardEvent): string | null { + if ([...event.key].length !== 1 || event.metaKey) return null; + const altGraph = event.getModifierState("AltGraph"); + if (event.ctrlKey && !altGraph) return null; + if (event.altKey && !altGraph && !isMacPlatform(navigator.platform)) return null; + return event.key; +} + function overlayModeForCommand(command: string | null): SearchOverlayMode | null { if (command === null) return null; return command in OVERLAY_MODE_BY_COMMAND @@ -148,8 +167,10 @@ export function CommandPaletteHost({ children }: { children: ReactNode }) { }; }, []); - // While the chunk loads, the app behind the palette is inert and nothing has - // focus. Text typed then becomes the palette's query, and Escape closes it. + // While a view's chunk loads (the palette, or content search inside it), the + // app behind it is inert and nothing has focus. Text typed then becomes the + // view's query, and Escape closes the palette (content and file search + // handle their own Escape below). const loadingQuery = state.openIntent === null ? "" @@ -157,20 +178,22 @@ export function CommandPaletteHost({ children }: { children: ReactNode }) { ? state.openIntent.query : null; useLayoutEffect(() => { - if (!state.open || state.mode !== "command" || loadingQuery === null) return; + if (!state.open || loadingQuery === null) return; const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (document.getElementById(COMMAND_PALETTE_ELEMENT_ID) !== null) return; - if (event.isComposing || event.metaKey || event.ctrlKey || event.altKey) return; - if (event.key !== "Escape" && event.key !== "Backspace" && event.key.length !== 1) return; - event.preventDefault(); - event.stopPropagation(); + if (paletteTextInputMounted() || event.isComposing) return; + const text = typedText(event); + if (text === null && event.key !== "Backspace" && event.key !== "Escape") return; if (event.key === "Escape") { + if (state.mode !== "command") return; + event.preventDefault(); + event.stopPropagation(); setOpen(false); return; } - const query = - event.key === "Backspace" ? loadingQuery.slice(0, -1) : loadingQuery + event.key; - dispatch({ _tag: "OpenSearch", query }); + event.preventDefault(); + event.stopPropagation(); + const query = text === null ? loadingQuery.slice(0, -1) : loadingQuery + text; + dispatch({ _tag: "TypeAhead", query }); }; window.addEventListener("keydown", onKeyDown, true); return () => window.removeEventListener("keydown", onKeyDown, true); diff --git a/apps/web/src/components/files/ProjectFilePicker.tsx b/apps/web/src/components/files/ProjectFilePicker.tsx index 38d21f6c1005e..7de429624e953 100644 --- a/apps/web/src/components/files/ProjectFilePicker.tsx +++ b/apps/web/src/components/files/ProjectFilePicker.tsx @@ -18,6 +18,8 @@ import { useProjectFilePickerQuery } from "./projectFilesQueryState"; interface ProjectFilePickerProps { readonly setOpen: (open: boolean) => void; + /** Text typed while the palette loaded. */ + readonly initialQuery?: string; } function HighlightedFuzzyText(props: { @@ -70,7 +72,7 @@ function EmptyProjectFilePicker() { function OpenProjectFilePicker(props: ProjectFilePickerProps & { target: ActiveProjectTarget }) { const { target } = props; - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(props.initialQuery ?? ""); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const result = useProjectFilePickerQuery( target.environmentId, @@ -159,5 +161,11 @@ export function ProjectFilePicker(props: ProjectFilePickerProps) { return ; } - return ; + return ( + + ); } diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index f429d36e3f609..6a5961c7ead94 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -19,6 +19,8 @@ import { HighlightedSearchLine } from "./HighlightedSearchLine"; interface ProjectContentSearchDialogProps { readonly onOpenChange: (open: boolean) => void; + /** Text typed while the dialog loaded. */ + readonly initialQuery?: string; } /** @@ -105,10 +107,11 @@ function EmptyContentSearchDialog() { function OpenContentSearchDialog(props: { readonly onOpenChange: (open: boolean) => void; readonly target: ActiveProjectTarget; + readonly initialQuery?: string; }) { const { target } = props; const { resolvedTheme } = useTheme(); - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(props.initialQuery ?? ""); const [caseSensitive, setCaseSensitive] = useState(false); const [wholeWord, setWholeWord] = useState(false); const [useRegex, setUseRegex] = useState(false); @@ -330,6 +333,7 @@ export function ProjectContentSearchDialog(props: ProjectContentSearchDialogProp key={`${target.environmentId}:${target.cwd}`} onOpenChange={props.onOpenChange} target={target} + {...(props.initialQuery ? { initialQuery: props.initialQuery } : {})} /> ) : ( From c4b094af9fe1e9dd9be43eb3ca3dfe4528a13682 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:43:17 +0000 Subject: [PATCH 072/241] chore(web): clear React Compiler hints in settings rows and scoped saves --- .../settings/KeybindingsSettings.tsx | 43 ++++++++++--------- .../components/settings/useScopedSettings.ts | 18 ++++---- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 32d3e80c49bd6..5fb3be3232fdb 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1245,26 +1245,29 @@ interface KeybindingsGroupsProps extends KeybindingRowActions { function KeybindingsGroups(props: KeybindingsGroupsProps) { const { groups, anchorIds, savingCommand, ...rowActions } = props; const rowLimit = useProgressiveRowLimit(INITIAL_KEYBINDING_ROWS); - let rowsBefore = 0; - return groups.map((group) => { - const budget = rowLimit - rowsBefore; - rowsBefore += group.rows.length; - if (budget <= 0) return null; - const rows = group.rows.length > budget ? group.rows.slice(0, budget) : group.rows; - return ( - - {rows.map((row) => ( - - ))} - - ); - }); + const shownGroups: Array<{ group: KeybindingGroup; rows: KeybindingGroup["rows"] }> = []; + let budget = rowLimit; + for (const group of groups) { + if (budget <= 0) break; + shownGroups.push({ + group, + rows: group.rows.length > budget ? group.rows.slice(0, budget) : group.rows, + }); + budget -= group.rows.length; + } + return shownGroups.map(({ group, rows }) => ( + + {rows.map((row) => ( + + ))} + + )); } /** Enough rows to fill a tall window; the rest render right after the first paint. */ diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index a48a281c9c7af..195df72979701 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -95,14 +95,16 @@ function useRunScopedPlan() { appAtomRegistry.get(environmentServerConfigsAtom).get(request.environmentId) ?.settings ?? null, ); - let saved = false; - try { - const result = await persistServer(request); - saved = result._tag === "Success"; - return result; - } finally { - settle(saved); - } + return persistServer(request).then( + (result) => { + settle(result._tag === "Success"); + return result; + }, + (error: unknown) => { + settle(false); + throw error; + }, + ); }, persistClientSettingsPatch, ).then(({ failedEnvironments, savedEnvironments, savedEnvironmentCount }) => { From 5d279b35ba11ccab0f841954dbec7d8a489e48f4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:43:18 +0000 Subject: [PATCH 073/241] fix(client-runtime): a session fetch that fails while connecting retries The session's scopes are fetched once per connect attempt and revalidated after 30 s. When that fetch failed while the socket connected (a server still starting, a network blip), permission-gated UI stayed off until the revalidation. Network, timeout, undeclared-status and internal errors now retry with backoff (0.5 s doubling to 4 s, five times). --- packages/client-runtime/src/state/session.ts | 30 +++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index bc16632164dc8..5524c194e6291 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,8 +1,10 @@ import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equivalence from "effect/Equivalence"; import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { HttpClient } from "effect/http"; @@ -40,6 +42,25 @@ function initialConfigOption( // the settings UI) in a loading state for long. const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; +// The session is fetched once per connect attempt and revalidated after 30 s. +// A fetch that fails while the socket connects (a server still starting, a +// network blip) retries, so permission-gated UI does not stay off until then. +const SESSION_STATE_RETRIES = 5; +const SESSION_STATE_RETRY_SCHEDULE = Schedule.exponential("500 millis").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(4))), + ), +); + +function isTransientSessionStateError(error: { readonly _tag: string }): boolean { + return ( + error._tag === "RemoteEnvironmentAuthFetchError" || + error._tag === "RemoteEnvironmentAuthTimeoutError" || + error._tag === "RemoteEnvironmentAuthUndeclaredStatusError" || + error._tag === "EnvironmentInternalError" + ); +} + /** * Read the granted scopes of this client's session on one environment via its * `/api/auth/session` endpoint, using the connection's authentication method @@ -155,7 +176,14 @@ function makeEnvironmentSessionAtoms( prepared, signer, remoteAuthorization, - }).pipe(Effect.provideService(HttpClient.HttpClient, client.value)); + }).pipe( + Effect.provideService(HttpClient.HttpClient, client.value), + Effect.retry({ + schedule: SESSION_STATE_RETRY_SCHEDULE, + times: SESSION_STATE_RETRIES, + while: isTransientSessionStateError, + }), + ); }); }) .pipe( From 2ebece487d67271192c6b6446092c8c876478714 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:43:18 +0000 Subject: [PATCH 074/241] fix(web): theme switches no longer animate the whole app The integration kept ui-runtime's removal of the .no-transitions rule but dropped its matching useTheme change, so the class useTheme still toggles during a theme change did nothing. The rule is back. --- apps/web/src/index.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 66a02f2836434..b59b8323f8a33 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1075,6 +1075,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil background-size: var(--surface-grain-size); } +/* Suppress all transitions during theme changes */ +.no-transitions, +.no-transitions *, +.no-transitions *::before, +.no-transitions *::after { + transition-duration: 0s !important; + animation-duration: 0s !important; +} + :root { color-scheme: light; --radius: 0.625rem; From 7d0bb8260577b5e677f14055b962271cd2696560 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 16:43:18 +0000 Subject: [PATCH 075/241] fix(web): keybinding reloads from config deltas show their toast A server that sends config deltas reports a keybindings.json reload as a configDelta, never as keybindingsUpdated, so the Keybindings updated toast and the invalid configuration warning never showed. The toast policy now reads a delta that changes the keybindings or the issues as a reload, and does not repeat a standing warning when only other issues change. --- .../KeybindingsUpdateToast.logic.test.ts | 33 ++++++++++++ .../KeybindingsUpdateToast.logic.ts | 52 ++++++++++++++++--- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts index de5a2123cde47..dc60f2442956e 100644 --- a/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts @@ -59,6 +59,39 @@ describe("keybindings update toast policy", () => { }); }); + it("reads keybinding reloads from config deltas", () => { + const controller = createKeybindingsUpdateToastController({}); + const delta = ( + changes: Extract["changes"], + ): ServerConfigStreamEvent => ({ + version: 1, + type: "configDelta", + configVersion: "v2", + changes, + removed: [], + providerChanges: [], + }); + const malformed = { + kind: "keybindings.malformed-config", + message: "Expected JSON array", + } as const; + + expect(controller.handle(delta({ keybindings: [] }))).toEqual({ _tag: "Success" }); + expect(controller.handle(delta({ issues: [malformed] }))).toEqual({ + _tag: "InvalidConfiguration", + message: "Expected JSON array", + }); + // Another issue changes while the file is still malformed. + expect( + controller.handle( + delta({ + issues: [malformed, { kind: "keybindings.invalid-entry", message: "Unknown", index: 3 }], + }), + ), + ).toBeNull(); + expect(controller.handle(delta({ cwd: "/repo" }))).toBeNull(); + }); + it("ignores unrelated server config notifications", () => { const controller = createKeybindingsUpdateToastController({}); diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.ts index f6a47f50cfc65..fe356e8964a1f 100644 --- a/apps/web/src/components/KeybindingsUpdateToast.logic.ts +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.ts @@ -1,4 +1,4 @@ -import type { ServerConfigStreamEvent } from "@t3tools/contracts"; +import type { ServerConfig, ServerConfigStreamEvent } from "@t3tools/contracts"; export const KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS = 2_000; @@ -10,24 +10,60 @@ export interface KeybindingsUpdateToastController { readonly handle: (event: ServerConfigStreamEvent | null) => KeybindingsUpdateToastDecision | null; } +interface KeybindingsUpdate { + readonly keybindingsChanged: boolean; + /** The config's issues, when the event carries them. */ + readonly issues: ServerConfig["issues"] | null; +} + +/** + * A keybindings reload, as a `keybindingsUpdated` event or, from a server + * that sends config deltas, as a delta that changes the keybindings or the + * issues (a malformed file leaves the bindings as they were). + */ +function keybindingsUpdateFromEvent( + event: ServerConfigStreamEvent | null, +): KeybindingsUpdate | null { + if (event?.type === "keybindingsUpdated") { + return { keybindingsChanged: true, issues: event.payload.issues }; + } + if (event?.type !== "configDelta") return null; + const { keybindings, issues } = event.changes; + if (keybindings === undefined && issues === undefined) return null; + return { keybindingsChanged: keybindings !== undefined, issues: issues ?? null }; +} + export function createKeybindingsUpdateToastController(input: { readonly now?: () => number; }): KeybindingsUpdateToastController { const now = input.now ?? Date.now; let lastSuccessToastAt: number | null = null; + // A delta that only changes other issues must not repeat a standing warning. + let reportedIssueMessage: string | null = null; return { handle: (event) => { - if (event?.type !== "keybindingsUpdated") { + const update = keybindingsUpdateFromEvent(event); + if (update === null) { return null; } - const issue = event.payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); - if (issue) { - return { - _tag: "InvalidConfiguration", - message: issue.message, - }; + if (update.issues !== null) { + const issue = update.issues.find((entry) => entry.kind.startsWith("keybindings.")); + const repeated = !update.keybindingsChanged && issue?.message === reportedIssueMessage; + reportedIssueMessage = issue?.message ?? null; + if (issue && !repeated) { + return { + _tag: "InvalidConfiguration", + message: issue.message, + }; + } + if (issue) { + return null; + } + } + if (!update.keybindingsChanged) { + return null; } const currentTime = now(); From b6c72d474533b480a103920b1d4497b62efc7d47 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 17:20:14 +0000 Subject: [PATCH 076/241] fix(web): an MCP app works again after its kept thread is shown again Hiding a kept thread view runs the frame's effect cleanup, which tore down the app's host, while the iframe stayed loaded. Shown again, a new host waited for an initialize the app had already sent, so theme, size and display-mode updates and the tool result never reached it, and a hidden app kept playing sound. A hidden frame now stops its app (the iframe goes to about:blank and is removed) and loads a new document with a new host when the view is shown, as leaving the thread used to. --- apps/web/src/components/chat/McpAppFrame.tsx | 30 ++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/McpAppFrame.tsx b/apps/web/src/components/chat/McpAppFrame.tsx index b7864c1035d3d..cdcf7658602af 100644 --- a/apps/web/src/components/chat/McpAppFrame.tsx +++ b/apps/web/src/components/chat/McpAppFrame.tsx @@ -250,6 +250,32 @@ export function McpAppFrame(props: { cancelled = true; }; }, [props.environmentId, props.threadId, props.itemId, app.tool]); + // A hidden kept thread view keeps its DOM while React runs its effect + // cleanups, so the host below is torn down but the app would keep running + // (and playing sound) and never initialize again. The frame stops while + // hidden and loads a new document when shown, as when leaving the thread + // unmounted it. + const [parked, setParked] = useState(false); + useEffect(() => { + if (parked) { + minting.current = false; + // oxlint-disable-next-line react/set-state-in-effect -- A kept view shown again loads a new document. + setSrc(null); + setNavigatedAway(false); + setDisplayMode("inline"); + setDocumentGeneration((value) => value + 1); + setParked(false); + return; + } + const box = boxRef.current; + return () => { + if (!box?.isConnected) return; + const frame = frameRef.current; + if (frame !== null) frame.src = "about:blank"; + setParked(true); + }; + }, [parked]); + // The bridge belongs to the captured document. A frame that navigates keeps // its window, so a second load stops the app rather than letting a page T3 // never served pose as it. This is not a confidentiality boundary: a frame @@ -499,7 +525,7 @@ export function McpAppFrame(props: { // the frame to another parent would reload the app, and the timeline's // rows contain fixed positioning, which the top layer escapes. {...(fullscreenSupported ? { popover: "manual" as const } : {})} - data-mcp-app-fullscreen={fullscreen ? "" : undefined} + data-mcp-app-fullscreen={fullscreen && !parked ? "" : undefined} className={cn( "relative size-full overflow-hidden", app.prefersBorder === true && !fullscreen && "rounded-lg border border-border", @@ -526,7 +552,7 @@ export function McpAppFrame(props: {

The {app.server} app left its page and was stopped

- ) : src !== null ? ( + ) : src !== null && !parked ? (