diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 68febbc6386ff..91b17666e8985 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -244,6 +244,20 @@ const bootstrap = Effect.gen(function* () { } 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)) { + yield* desktopWindow.ensureMain.pipe( + Effect.catch((error) => + logBootstrapWarning("failed to open main window while the backend starts", { + error: error.message, + }), + ), + ); + } yield* appActivation.start.pipe( Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 8cc1a9fc6433d..15ed2abe7517f 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -4,6 +4,8 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, DesktopTelemetryControlMessage, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -568,6 +570,36 @@ describe("DesktopBackendManager", () => { ), ); + // POSIX socketpairs reset when the reader exits with unread data; Windows uses named pipes. + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "survives a backend that exits with unread bootstrap input", + () => + Effect.scoped( + Effect.gen(function* () { + const uncaught: Array = []; + const onUncaught = (error: unknown) => { + uncaught.push(error); + }; + yield* Effect.acquireRelease( + Effect.sync(() => process.on("uncaughtException", onUncaught)), + () => Effect.sync(() => process.off("uncaughtException", onUncaught)), + ); + + // Reads one byte of the bootstrap line and exits, so its pipe resets. + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + executablePath: process.execPath, + args: ["-e", 'require("node:fs").readSync(3, Buffer.alloc(1)); process.exit(0)'], + cwd: process.cwd(), + desktopTelemetryStream: Stream.empty, + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, layerHealthyHttpClient))); + + assert.equal(exit.code.pipe(Option.getOrUndefined), 0); + assert.deepEqual(uncaught, []); + }), + ), + ); + it.effect("continues routing desktop telemetry control messages after an invalid line", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 900afce9b860a..538a912ba107d 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -67,6 +67,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), + waitForReady: () => Effect.succeed(true), currentConfig: Effect.succeedSome(config), }, ]), @@ -120,6 +121,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), + waitForReady: () => Effect.succeed(true), currentConfig: Effect.succeedSome({ ...config, bootstrap: { ...config.bootstrap, desktopBootstrapSecret: "desktop-secret" }, @@ -166,6 +168,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), + waitForReady: () => Effect.succeed(true), currentConfig: Effect.succeedSome(config), }, ]), @@ -232,4 +235,49 @@ describe("DesktopLocalEnvironmentAuth", () => { assert.strictEqual(yield* Ref.get(requestCount), 1); }).pipe(Effect.provide(TestClock.layer())), ); + + it.effect.each([true, false])("waits for backend readiness before exchange: %s", (ready) => + Effect.gen(function* () { + let requests = 0; + const pool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([ + { + id: PRIMARY_LOCAL_ENVIRONMENT_ID, + label: Effect.succeed("Local"), + waitForReady: () => + Effect.sleep(ready ? "40 seconds" : "2 minutes").pipe(Effect.as(ready)), + currentConfig: Effect.succeedSome(config), + }, + ]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); + const client = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests++; + return ready + ? tokenResponse(request) + : HttpClientResponse.fromWeb(request, new Response("", { status: 503 })); + }), + ), + ); + const result = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( + Effect.flatMap((auth) => auth.getBearerToken), + Effect.match({ onSuccess: (token) => token, onFailure: (error) => error._tag }), + Effect.forkChild, + Effect.provide( + DesktopLocalEnvironmentAuth.layer.pipe(Layer.provide(Layer.merge(pool, client))), + ), + ); + assert.equal(requests, 0); + yield* TestClock.adjust("3 minutes"); + const token = yield* Fiber.join(result); + assert.equal( + token, + ready ? "desktop-bearer-token" : "DesktopLocalEnvironmentAuthSessionBootstrapError", + ); + if (ready) assert.equal(requests, 1); + else assert.isAbove(requests, 0); + }).pipe(Effect.provide(TestClock.layer())), + ); }); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 4d95dd075cf3e..b4c82fa275cd8 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -19,6 +19,10 @@ import * as DesktopBackendPool from "./DesktopBackendPool.ts"; const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15); const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500); const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); +// The window opens while the backend starts, and a cold start can outlast the +// retry window above. The exchange waits for readiness first, up to this +// bound, so a backend that never becomes ready still fails it. +const BACKEND_START_TIMEOUT = Duration.minutes(2); const isTransientBearerBootstrapError = (error: RemoteEnvironmentRequestError): boolean => { switch (error._tag) { @@ -80,6 +84,10 @@ export const make = Effect.gen(function* () { const instances = yield* pool.list; const primary = instances.find((instance) => instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID); + // Returns at once for a ready backend or one that is not running. + if (primary !== undefined) { + yield* primary.waitForReady(BACKEND_START_TIMEOUT); + } const configOption = primary === undefined ? Option.none() : yield* primary.currentConfig; if (Option.isNone(configOption)) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cebdcbb779728..b5c42d8db4909 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -631,7 +631,7 @@ describe("DesktopWindow", () => { ); }); - it.effect("does not open a development window until the backend is ready", () => + it.effect("creates one window when bootstrap and backend readiness overlap", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); @@ -649,7 +649,13 @@ describe("DesktopWindow", () => { yield* desktopWindow.activate; assert.equal(yield* Ref.get(createCount), 0); - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + yield* Effect.all( + [ + desktopWindow.ensureMain, + desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), + ], + { concurrency: "unbounded" }, + ); assert.equal(yield* Ref.get(createCount), 1); assert.equal(createdWindowOptions[0]?.width, 1100); assert.equal(createdWindowOptions[0]?.height, 780); @@ -664,6 +670,53 @@ describe("DesktopWindow", () => { }), ); + it.effect("keeps a window closed during a cold start closed until activation", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); + const readyUrl = new URL("http://127.0.0.1:3773"); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const closeMain = Effect.sync(() => fakeWindow.windowListeners.get("closed")!()); + + yield* desktopWindow.ensureMain; + yield* closeMain; + yield* desktopWindow.handleBackendReady(readyUrl); + assert.equal(yield* Ref.get(createCount), 1); + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + + // A dock click while the backend restarts reopens the window at readiness. + yield* desktopWindow.handleBackendNotReady; + yield* closeMain; + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + yield* desktopWindow.handleBackendReady(readyUrl); + assert.equal(yield* Ref.get(createCount), 3); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("opens the main window at readiness when the early open failed", () => + Effect.gen(function* () { + const main = makeFakeBrowserWindow(); + const scenario = yield* makeSplashScenario([null, main.window]); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const earlyExit = yield* Effect.exit(desktopWindow.ensureMain); + assert.equal(earlyExit._tag, "Failure"); + + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(scenario.createCalls), 2); + assert.equal(Option.getOrThrow(yield* Ref.get(scenario.mainWindow)), main.window); + }).pipe(Effect.provide(scenario.layer)); + }), + ); + it.effect( "opens and reopens the window without backend readiness when local execution is disabled", () => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 1d76107425ed5..2ea491b2e0c6d 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as Electron from "electron"; @@ -299,6 +300,10 @@ export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; const backendReadyRef = yield* Ref.make(false); + const mainWindowCreation = yield* Semaphore.make(1); + // Set when the main window closes while the backend starts, so readiness + // leaves it closed. macOS keeps the app running; activation reopens it. + const mainClosedBeforeReadyRef = yield* Ref.make(false); const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); @@ -832,7 +837,14 @@ export const make = Effect.gen(function* () { window.on("closed", () => { clearDevelopmentLoadRetry(); clearBoundsPersist(); - void runPromise(electronWindow.clearMain(Option.some(window))); + void runPromise( + electronWindow.clearMain(Option.some(window)).pipe( + Effect.andThen(waitingForBackend), + Effect.flatMap((waiting) => + waiting ? Ref.set(mainClosedBeforeReadyRef, true) : Effect.void, + ), + ), + ); }); return window; @@ -851,7 +863,7 @@ export const make = Effect.gen(function* () { return existingWindow.value; } return yield* createMain; - }).pipe(Effect.withSpan("desktop.window.ensureMain")); + }).pipe(mainWindowCreation.withPermit, Effect.withSpan("desktop.window.ensureMain")); const revealOrCreateMain = Effect.gen(function* () { const window = yield* ensureMain; @@ -866,9 +878,7 @@ export const make = Effect.gen(function* () { const createMainIfBackendReady = Effect.gen(function* () { if (yield* waitingForBackend) return; - const existingWindow = yield* currentMainWindow; - if (Option.isSome(existingWindow)) return; - yield* createMain; + yield* ensureMain; }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); const showConnectingSplash = Effect.gen(function* () { @@ -956,6 +966,8 @@ export const make = Effect.gen(function* () { yield* electronWindow.reveal(existingWindow.value); return; } + // Asking for the window also undoes an earlier close, so readiness opens it. + yield* Ref.set(mainClosedBeforeReadyRef, false); const backendReady = yield* Ref.get(backendReadyRef); if (!backendReady) { const splash = yield* Ref.get(splashWindowRef); @@ -971,6 +983,7 @@ export const make = Effect.gen(function* () { handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { yield* Ref.set(backendReadyRef, true); yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); + if (yield* Ref.getAndSet(mainClosedBeforeReadyRef, false)) return; yield* createMainIfBackendReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 058d423d8e33f..036178f1a8bbf 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "@effect/vitest"; import { describe, vi } from "vite-plus/test"; +import * as NodeZlib from "node:zlib"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -214,6 +215,74 @@ it.layer( Layer.provideMerge(NodeServices.layer), ), )("static HTTP responses", (it) => { + it.effect("serves precompressed manifest assets and revalidates other files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-compressed-" }); + yield* fs.makeDirectory(path.join(staticDir, "assets")); + const body = 'export const build = "precompressed";\n'.repeat(100); + const asset = "assets/app-1234abcd.js"; + const br = NodeZlib.brotliCompressSync(body); + const gz = NodeZlib.gzipSync(body); + yield* fs.writeFileString(path.join(staticDir, asset), body); + yield* fs.writeFile(path.join(staticDir, `${asset}.br`), br); + yield* fs.writeFile(path.join(staticDir, `${asset}.gz`), gz); + yield* fs.writeFileString( + path.join(staticDir, "vite-manifest.json"), + JSON.stringify({ "src/app.ts": { file: asset, isEntry: true } }), + ); + // A hash-like filename and a compressed copy alone cannot make a custom file immutable. + const custom = "assets/custom-1234abcd.js"; + yield* fs.writeFileString(path.join(staticDir, custom), body); + yield* fs.writeFile(path.join(staticDir, `${custom}.br`), br); + const request = yield* makeStaticRequest(staticDir); + + for (const [acceptEncoding, encoding, length] of [ + ["br, gzip", "br", br.byteLength], + ["br;q=0, gzip", "gzip", gz.byteLength], + ["identity", undefined, Buffer.byteLength(body)], + ] as const) { + const response = yield* request(`/${asset}`, { + headers: { "accept-encoding": acceptEncoding }, + }); + expect(response.status).toBe(200); + expect(response.headers["content-encoding"]).toBe(encoding); + expect(response.headers["content-length"]).toBe(String(length)); + expect(response.headers["content-type"]).toContain("javascript"); + expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable"); + expect(response.headers["vary"]).toBe("Accept-Encoding"); + expect(yield* response.text).toBe(body); + const head = yield* request(`/${asset}`, { + method: "HEAD", + headers: { "accept-encoding": acceptEncoding }, + }); + expect(head.headers["content-encoding"]).toBe(encoding); + expect(head.headers["content-length"]).toBe(String(length)); + expect(yield* head.text).toBe(""); + const unchanged = yield* request(`/${asset}`, { + headers: { + "accept-encoding": acceptEncoding, + "if-none-match": response.headers["etag"]!, + }, + }); + expect(unchanged.status).toBe(304); + expect(unchanged.headers["vary"]).toBe("Accept-Encoding"); + expect(yield* unchanged.text).toBe(""); + } + const unlisted = yield* request(`/${custom}`, { headers: { "accept-encoding": "br" } }); + expect(unlisted.headers["cache-control"]).toBe("no-cache"); + expect(unlisted.headers["content-length"]).toBeUndefined(); + expect(yield* unlisted.text).toBe(body); + + yield* fs.remove(path.join(staticDir, `${asset}.br`)); + const fallback = yield* request(`/${asset}`, { headers: { "accept-encoding": "br, gzip" } }); + expect(fallback.headers["content-encoding"]).toBe("gzip"); + expect(fallback.headers["content-length"]).toBe(String(gz.byteLength)); + expect(yield* fallback.text).toBe(body); + }), + ); + it.effect("revalidates non-HTML files and returns changed contents", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 9f3f1e87ab4df..2fdcd0f4f1cfc 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -513,8 +513,14 @@ const loadImmutableBuildAssets = Effect.gen(function* () { if (!staticDir) return new Set(); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - return yield* fileSystem.readFileString(path.join(staticDir, ".vite", "manifest.json")).pipe( + return yield* fileSystem.readFileString(path.join(staticDir, "vite-manifest.json")).pipe( Effect.flatMap(decodeBuildManifest), + // Older builds and custom static directories can still use Vite's default location. + Effect.catch(() => + fileSystem + .readFileString(path.join(staticDir, ".vite", "manifest.json")) + .pipe(Effect.flatMap(decodeBuildManifest)), + ), Effect.map( (manifest) => new Set( @@ -550,6 +556,26 @@ const streamStaticFile = (file: FileSystem.File, size: bigint) => }), ); +/** Match the compression middleware's preference and reject malformed quality values. */ +function acceptedStaticEncodings(header: string | undefined): ReadonlyArray<"br" | "gzip"> { + if (!header?.trim()) return []; + const accepted = new Map(); + for (const part of header.split(",")) { + const member = part + .trim() + .toLowerCase() + .replace(/[ \t]*;[ \t]*/g, ";"); + const match = /^([a-z0-9!#$%&'*+.^_`|~-]+)(?:;q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/.exec( + member, + ); + if (!match) return []; + accepted.set(match[1]!, match[2] === undefined ? 1 : Number(match[2])); + } + return (["br", "gzip"] as const).filter( + (encoding) => (accepted.get(encoding) ?? accepted.get("*") ?? 0) > 0, + ); +} + const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( function* (immutableBuildAssets: ReadonlySet) { const request = yield* HttpServerRequest.HttpServerRequest; @@ -666,6 +692,19 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( } const contentType = isHtml ? "text/html; charset=utf-8" : mimeType; + // Only manifest-backed hashed assets can trust an adjacent compressed copy. + // Custom static files may be overwritten without updating their sidecars. + if (immutable) { + for (const encoding of acceptedStaticEncodings(request.headers["accept-encoding"])) { + const compressed = yield* openStaticFile(`${filePath}.${encoding === "br" ? "br" : "gz"}`); + if (!compressed) continue; + return HttpServerResponse.stream(streamStaticFile(compressed.file, compressed.info.size), { + headers: { ...headers, "Content-Encoding": encoding, Vary: "Accept-Encoding" }, + contentType, + contentLength: Number(compressed.info.size), + }); + } + } return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), { headers, contentType, diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts index de5e759a8425d..4d12c1d8d7ac3 100644 --- a/apps/server/src/httpResponseErrorGuard.test.ts +++ b/apps/server/src/httpResponseErrorGuard.test.ts @@ -3,7 +3,10 @@ import * as NodeHttp from "node:http"; import * as NodeNet from "node:net"; import { afterEach, describe, expect, it } from "vite-plus/test"; -import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; +import { + guardHttpResponseWriteErrors, + holdRequestsUntilServing, +} from "./httpResponseErrorGuard.ts"; const servers: NodeHttp.Server[] = []; @@ -101,3 +104,55 @@ describe("guardHttpResponseWriteErrors", () => { expect(writeErrors).toEqual([]); }); }); + +describe("holdRequestsUntilServing", () => { + it("answers requests and upgrades accepted before handlers attach", async () => { + const server = holdRequestsUntilServing(guardHttpResponseWriteErrors(NodeHttp.createServer())); + const port = await listen(server); + const parked = Promise.withResolvers(); + let parsed = 0; + server.on("connection", (socket) => + socket.once("data", () => { + if (++parsed === 2) parked.resolve(); + }), + ); + const response = fetchStatus(port, "/early"); + const upgrade = new Promise((resolve, reject) => { + const client = NodeNet.connect(port, "127.0.0.1", () => { + client.write( + "GET /ws HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", + ); + }); + client.once("data", (data) => { + client.destroy(); + resolve(data.toString().split("\r\n")[0]!); + }); + client.on("error", reject); + }); + await parked.promise; + server.on("request", (_, response) => { + response.writeHead(204); + response.end(); + }); + server.on("upgrade", (_, socket) => socket.end("HTTP/1.1 101 Switching Protocols\r\n\r\n")); + await expect(response).resolves.toBe(204); + await expect(upgrade).resolves.toBe("HTTP/1.1 101 Switching Protocols"); + }); + + it("hands parked requests to the handler before later ones", async () => { + const server = holdRequestsUntilServing(NodeHttp.createServer()); + const received: Array = []; + server.emit("request", "parked"); + // A request whose I/O callback is already queued when the handler attaches. + const laterEmitted = new Promise((resolve) => + setImmediate(() => { + server.emit("request", "later"); + resolve(); + }), + ); + server.on("request", (request) => received.push(request)); + await laterEmitted; + + expect(received).toEqual(["parked", "later"]); + }); +}); diff --git a/apps/server/src/httpResponseErrorGuard.ts b/apps/server/src/httpResponseErrorGuard.ts index 1461e6a5ead90..36d15b974ac8e 100644 --- a/apps/server/src/httpResponseErrorGuard.ts +++ b/apps/server/src/httpResponseErrorGuard.ts @@ -22,3 +22,35 @@ export function guardHttpResponseWriteErrors( }); return server; } + +/** + * `NodeHttpServer` listens while its layer builds but attaches its request + * and upgrade handlers only when `HttpRouter.serve` runs, after the whole + * runtime graph: about a second at startup. Node accepts connections in that + * window, and with no handler they were never answered, so a client that + * reconnected into it waited out its socket timeout. Park those requests and + * hand them to the first real handler, which answers them like any request + * that arrived a moment later. + */ +export function holdRequestsUntilServing(server: T): T { + for (const event of ["request", "upgrade"] as const) { + const parked: Array> = []; + const park = (...args: ReadonlyArray) => { + parked.push(args); + }; + const onNewListener = (name: string | symbol) => { + if (name !== event) return; + server.off("newListener", onNewListener); + server.off(event, park); + // "newListener" fires before the listener is added. A microtask runs + // once it is, and before any later connection's I/O callback, so parked + // requests still reach the handler in arrival order. + queueMicrotask(() => { + for (const args of parked.splice(0)) server.emit(event, ...args); + }); + }; + server.on(event, park); + server.on("newListener", onNewListener); + } + return server; +} diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 51d072951b4e0..5d7f87494d5c5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -225,6 +225,31 @@ describe("CodexAdapterV2 assistant message streaming", () => { }), ); + it.effect("flushes each item's first text before another item's pending interval", () => + Effect.gen(function* () { + const updates: string[] = []; + const coalescer = yield* makeProviderTextDeltaCoalescer({ + flushIntervalMs: 50, + emit: (update) => + Effect.sync(() => { + updates.push(update.text); + }), + }); + yield* coalescer.append({ turnId: "turn", itemId: "reasoning", delta: "think" }); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.deepEqual(updates, ["think"]); + yield* coalescer.append({ turnId: "turn", itemId: "reasoning", delta: "ing" }); + yield* coalescer.append({ turnId: "turn", itemId: "reasoning", delta: "..." }); + yield* coalescer.append({ turnId: "turn", itemId: "answer", delta: "answer" }); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.deepEqual(updates, ["think", "answer"]); + yield* TestClock.adjust("50 millis"); + assert.deepEqual(updates, ["think", "answer", "thinking..."]); + }), + ); + it.effect("coalesces multiple token deltas into one assistant update per interval", () => Effect.gen(function* () { const updates = yield* Ref.make>([]); diff --git a/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts b/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts index 3729cb7073f9e..9149b76fa82ec 100644 --- a/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts +++ b/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts @@ -102,9 +102,9 @@ export const makeProviderTextDeltaCoalescer = Effect.fn("makeProviderTextDeltaCo ? Effect.void : Effect.uninterruptible( Effect.gen(function* () { - const shouldSchedule = yield* flushLock.withPermit( + const [shouldSchedule, firstDelta] = yield* flushLock.withPermit( Effect.gen(function* () { - yield* Ref.update(buffered, (current) => { + const firstDelta = yield* Ref.modify(buffered, (current) => { const key = providerTextBufferKey(turnId, itemId); const existing = current.get(key); const next = new Map(current); @@ -114,12 +114,30 @@ export const makeProviderTextDeltaCoalescer = Effect.fn("makeProviderTextDeltaCo text: `${existing?.text ?? ""}${delta}`, dirty: true, }); - return next; + return [existing === undefined, next] as const; }); - return yield* Ref.modify(flushScheduled, (scheduled) => [!scheduled, true]); + const shouldSchedule = yield* Ref.modify(flushScheduled, (scheduled) => [ + !scheduled, + true, + ]); + return [shouldSchedule, firstDelta] as const; }), ); - if (shouldSchedule) { + if (firstDelta) { + // An item's first text flushes on the next tick, so the first + // token is not held for the interval; later deltas coalesce. + // Another item's pending interval flush keeps its schedule. + yield* ( + shouldSchedule + ? flushDirty + : drain({ + predicate: (message) => + message.turnId === turnId && message.itemId === itemId, + completed: false, + onlyDirty: true, + }) + ).pipe(Effect.interruptible, Effect.forkIn(coalescerScope)); + } else if (shouldSchedule) { yield* Effect.sleep(Duration.millis(Math.max(1, input.flushIntervalMs))).pipe( Effect.andThen(flushDirty), Effect.interruptible, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 29ba67bdf6efe..7a0a5468ba99e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2366,11 +2366,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events, command, )({ - type: "thread.visited", + type: "thread.visit-recorded", threadId: command.threadId, providerInstanceId: thread.providerInstanceId, occurredAt: yield* DateTime.now, - payload: movesForward ? { ...thread, lastVisitedAt: visitedAt.value } : thread, + payload: { lastVisitedAt: movesForward ? visitedAt.value : thread.lastVisitedAt }, }); }); @@ -3282,11 +3282,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events, command, )({ - type: "thread.pull-request-link-updated", + ...(command.type === "thread.pull-request-link.sync" + ? { + type: "thread.pull-request-link-synced" as const, + payload: { + ...normalizeThreadPullRequestKey(command), + snapshot: command.snapshot, + stack: command.stack, + }, + } + : { type: "thread.pull-request-link-updated" as const, payload: link }), threadId: command.threadId, providerInstanceId: updatedThread.providerInstanceId, occurredAt: now, - payload: link, }); return; } diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index f8acaf065cb93..3cbbe183cce3f 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -217,11 +217,14 @@ export const layer: Layer.Layer< "thread.model-selection-updated", "thread.provider-switched", ]; - // Read-state events carry the whole thread too, but client reducers apply - // them without bumping updatedAt. They supersede only each other: a newer + // Read-state events do not bump updatedAt. They supersede only each other: a newer // visit must not delete the rename that carries the activity timestamp, // while any newer activity-bearing event supersedes both groups. - const READ_STATE_THREAD_EVENT_TYPES = ["thread.visited", "thread.marked-unread"]; + const READ_STATE_THREAD_EVENT_TYPES = [ + "thread.visited", + "thread.marked-unread", + "thread.visit-recorded", + ]; const supersedableThreadEventTypes = new Set(SUPERSEDABLE_THREAD_EVENT_TYPES); const readStateThreadEventTypes = new Set(READ_STATE_THREAD_EVENT_TYPES); @@ -280,7 +283,7 @@ export const layer: Layer.Layer< AND event.event_type IN ('message.updated', 'node.updated') THEN json_extract(event.payload_json, '$.id') WHEN event.application_event_version = 2 - AND event.event_type = 'thread.pull-request-link-updated' + AND event.event_type IN ('thread.pull-request-link-updated', 'thread.pull-request-link-synced') THEN json_array(json_extract(event.payload_json, '$.host'), json_extract(event.payload_json, '$.repository'), json_extract(event.payload_json, '$.number')) @@ -322,13 +325,15 @@ export const layer: Layer.Layer< obsolete.push(row.sequence); } else retainedReadStateThreadIds.add(row.stream_id); } else if ( - row.event_type === "thread.pull-request-link-updated" && + (row.event_type === "thread.pull-request-link-updated" || + row.event_type === "thread.pull-request-link-synced") && retainedThreadIds.has(row.stream_id) ) { // A newer whole-thread payload already carries this link's state. obsolete.push(row.sequence); } else if ( row.event_type === "thread.pull-request-link-updated" || + row.event_type === "thread.pull-request-link-synced" || row.event_type === "message.updated" || row.event_type === "node.updated" ) { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 74377160a8204..97573037d646e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -33,6 +33,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as Statement from "effect/sql/Statement"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; +import ProjectionThreadSweepIndexes from "../persistence/Migrations/065_ProjectionThreadSweepIndexes.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -2714,6 +2715,18 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.deepEqual(visited.thread.lastVisitedAt, createdAt); assert.deepEqual(visited.thread.updatedAt, createdAt); + yield* projectionStore.apply({ + id: EventId.make("event:projection-read-state:visit-recorded"), + type: "thread.visit-recorded", + threadId, + occurredAt: markedUnreadOccurredAt, + payload: { lastVisitedAt: visitedOccurredAt }, + }); + const recorded = yield* projectionStore.getThreadProjection(threadId); + assert.deepEqual(recorded.thread.lastVisitedAt, visitedOccurredAt); + assert.deepEqual(recorded.thread.updatedAt, createdAt); + assert.equal(recorded.thread.title, thread.title); + yield* projectionStore.apply({ id: EventId.make("event:projection-read-state:marked-unread"), type: "thread.marked-unread", @@ -3601,7 +3614,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ["settledOverride", "settled"], ]) { yield* sql`UPDATE orchestration_v2_projection_threads - SET payload_json = json_set(payload_json, ${`$.${field}`}, ${value}) + SET payload_json = json_set(payload_json, ${`$.${field}`}, ${value}), + archived_at = CASE WHEN ${field} = 'archivedAt' THEN ${value} ELSE archived_at END WHERE thread_id = ${threadId}`; assert.isUndefined( (yield* store.getLimitRecoveryCandidates({ now, autoResume: true, snooze: false })).find( @@ -3609,7 +3623,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ), ); yield* sql`UPDATE orchestration_v2_projection_threads - SET payload_json = ${originalRow!.payload_json} WHERE thread_id = ${threadId}`; + SET payload_json = ${originalRow!.payload_json}, archived_at = NULL + WHERE thread_id = ${threadId}`; } yield* sql`UPDATE orchestration_v2_projection_threads SET deleted_at = ${DateTime.formatIso(now)} WHERE thread_id = ${threadId}`; assert.isUndefined( @@ -3644,6 +3659,16 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { now: reset, })).find((row) => row.id === threadId)!; assert.deepEqual(due.limitRecovery, recovery); + // A database can lack the sweep index (its migration id taken by another build); the + // recovery sweep still finds the thread. + yield* sql`DROP INDEX orchestration_v2_projection_threads_active_idx`; + assert.deepEqual( + (yield* store.getLimitRecoveryCandidates({ ...recoveryOptions, now: reset })).find( + (row) => row.id === threadId, + )?.limitRecovery, + recovery, + ); + yield* ProjectionThreadSweepIndexes; yield* sql`INSERT INTO orchestration_v2_projection_runtime_requests (runtime_request_id, thread_id, node_id, kind, status, created_at, payload_json) VALUES ('limit-shell:pending-request', ${threadId}, ${original.rootNodeId}, 'approval', 'pending', ${DateTime.formatIso(now)}, '{}')`; @@ -5905,7 +5930,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { id: EventId.make(`event:watched-pull-request:${id}`), type: "thread.pull-request-synced", threadId, - occurredAt: at, + occurredAt: DateTime.add(at, { seconds: 1 }), payload: { ...thread, pullRequests }, }); const project = { title: "Project" }; @@ -5943,6 +5968,48 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }, ]); assert.equal(yield* phase, "running"); + assert.deepEqual((yield* store.getThread(threadId)).updatedAt, at); + + const snapshot = { + state: "open" as const, + title: "Updated pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: DateTime.formatIso(at), + syncedAt: DateTime.formatIso(at), + }; + yield* store.apply({ + id: EventId.make("event:watched-pull-request:link-synced"), + type: "thread.pull-request-link-synced", + threadId, + occurredAt: DateTime.add(at, { seconds: 1 }), + payload: { + host: link.host, + repository: link.repository, + number: link.number, + snapshot, + stack: null, + }, + }); + const synced = yield* store.getThread(threadId); + assert.deepEqual(synced.pullRequests?.[0]?.snapshot, snapshot); + assert.isDefined(synced.pullRequests?.[0]?.watch); + assert.deepEqual(synced.updatedAt, at); + assert.equal(yield* phase, "running"); + assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); + + // An older build sharing the database writes only the payload; the sweep still follows it. + const sql = yield* SqlClient.SqlClient; + const [stored] = yield* sql<{ readonly payload_json: string }>` + SELECT payload_json FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId}`; + yield* sql`UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, '$.pullRequests', json('[]')) + WHERE thread_id = ${threadId}`; + assert.deepEqual(yield* store.getThreadsWithPullRequests(), []); + yield* sql`UPDATE orchestration_v2_projection_threads + SET payload_json = ${stored!.payload_json} WHERE thread_id = ${threadId}`; + assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); yield* syncPullRequests("unwatched", [link]); assert.deepEqual((yield* store.getThreadShell(threadId))?.pendingBackgroundTasks, []); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 002ef7bafa950..67669ea65f355 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -13,7 +13,10 @@ import { threadErrorSummary, usageLimitRunPresentedAsLatest, } from "@t3tools/shared/orchestrationV2ThreadError"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { + applyThreadPullRequestLinkSync, + threadPullRequestsOf, +} from "@t3tools/shared/threadPullRequests"; import type { OrchestrationV2AppThread, OrchestrationV2CheckpointScope, @@ -758,12 +761,23 @@ export function applyToProjection( ...base, thread: event.payload, }; + // A host refresh is not activity, matching the stored projection. + case "thread.pull-request-link-synced": + return { + ...projection, + thread: applyThreadPullRequestLinkSync(projection.thread, event.payload), + }; case "thread.visited": case "thread.marked-unread": return { ...projection, thread: event.payload, }; + case "thread.visit-recorded": + return { + ...projection, + thread: { ...projection.thread, lastVisitedAt: event.payload.lastVisitedAt }, + }; case "run.created": case "run.updated": return withLocalVisibleTurnItems({ @@ -1930,6 +1944,33 @@ export const layer: Layer.Layer = `; break; } + // These events carry one field rather than the thread, so they patch the stored row. + // A link sync keeps the thread's activity timestamp, just like its legacy event. + case "thread.pull-request-link-synced": + case "thread.visit-recorded": { + const rows = yield* sql` + SELECT payload_json + FROM orchestration_v2_projection_threads + WHERE thread_id = ${event.threadId} + LIMIT 1 + `; + const row = rows[0]; + if (row === undefined) break; + const thread = yield* decodeThreadPayload(row.payload_json); + const updatedThread = + event.type === "thread.visit-recorded" + ? { ...thread, lastVisitedAt: event.payload.lastVisitedAt } + : applyThreadPullRequestLinkSync(thread, event.payload); + const payloadJson = yield* encodeThreadPayload(updatedThread); + yield* sql` + UPDATE orchestration_v2_projection_threads + SET + updated_at = ${stringField(parseEncodedPayload(payloadJson), "updatedAt")}, + payload_json = ${payloadJson} + WHERE thread_id = ${event.threadId} + `; + break; + } case "run.created": case "run.updated": { const payloadJson = yield* encodeRunPayload(event.payload); @@ -2704,6 +2745,9 @@ export const layer: Layer.Layer = event.type !== "thread.unpinned" && event.type !== "thread.pin-reordered" && event.type !== "thread.visited" && + event.type !== "thread.visit-recorded" && + event.type !== "thread.pull-request-link-synced" && + event.type !== "thread.pull-request-synced" && event.type !== "thread.marked-unread" && event.type !== "thread.metadata-updated" && event.type !== "thread.runtime-mode-updated" && @@ -5274,6 +5318,8 @@ export const layer: Layer.Layer = ), ); + // The settlement filters and fork source spell the sweep index's expressions (migration 065) + // exactly, so a scan of active threads reads them from the index instead of each payload. const selectShellThreadRows = ( threadId?: ThreadId, { location, projectId, unsettledOnly = false }: ShellSnapshotOptions = {}, @@ -5454,9 +5500,9 @@ export const layer: Layer.Layer = projectId === undefined ? sql`` : sql` AND t.project_id = ${projectId}` }${ location === "active" - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NULL` + ? sql` AND t.archived_at IS NULL` : location === "archive" - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NOT NULL` + ? sql` AND t.archived_at IS NOT NULL` : sql`` }${ unsettledOnly @@ -5635,7 +5681,8 @@ export const layer: Layer.Layer = LIMIT 1 ) WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`} - AND json_extract(t.payload_json, '$.archivedAt') IS NULL + -- These match the sweep index (migration 065), which holds the values. + AND t.archived_at IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NULL AND json_extract(t.payload_json, '$.pinnedAt') IS NULL AND json_extract(t.payload_json, '$.autoSettleDisabledAt') IS NULL @@ -5721,7 +5768,8 @@ export const layer: Layer.Layer = SELECT payload_json FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} - AND json_extract(payload_json, '$.archivedAt') IS NULL + AND archived_at IS NULL + -- Read from the sweep index (migration 065) rather than each payload. AND json_array_length(payload_json, '$.pullRequests') > 0 ORDER BY updated_at ASC, thread_id ASC `; diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index ea8080f115870..1da40ba3c0058 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1187,4 +1187,46 @@ describe("PullRequestSyncReactor", () => { }), ), ); + + it.effect("retries a link whose first sync failed when a sibling link syncs", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + let hostDown = true; + const link = makeLink(7, { state: "open" }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [link, makeLink(8)] })]), + summary: (input) => + Effect.suspend(() => + hostDown && input.number === 8 + ? Effect.fail( + new PullRequestOperationError({ operation: "summary", detail: "host down" }), + ) + : Effect.succeed(makeSummary(input)), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + hostDown = false; + yield* Ref.set(fixture.syncCommands, []); + + yield* Queue.offer(fixture.domainEvents, { + type: "thread.pull-request-link-synced", + id: EventId.make("event:one:link-synced"), + threadId: ThreadId.make("one"), + occurredAt: DateTime.makeUnsafe(NOW), + payload: { ...link, snapshot: link.snapshot! }, + }); + // The thread's lookup, then the requested sweep. + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => command.number), + [8], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 6bde4404fb7f1..ca1d4817c8eee 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -31,7 +31,7 @@ import * as Stream from "effect/Stream"; import * as GitManager from "../git/GitManager.ts"; import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; -import { forkParked } from "../serverActivation.ts"; +import { forkBackground, forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import { isTerminalRunStatus } from "./ThreadManagementService.ts"; @@ -209,7 +209,9 @@ export const make = Effect.gen(function* () { const linkedThisSweep = new Set(); const persistence = yield* Semaphore.make(1); - const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* ( + // One call per thread link per sweep (thousands a minute on large installs). + // Its rare dispatches stay traced under the group span. + const syncEntry = Effect.fnUntraced(function* ( entry: LinkEntry, fields: SnapshotFields, fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null, @@ -414,6 +416,12 @@ export const make = Effect.gen(function* () { ), Effect.catchCause(logSkipped("pull request refresh after run skipped", { threadId })), ); + const requestUnsyncedLinks = (links: ReadonlyArray) => + Effect.forEach( + visibleThreadPullRequests(links).filter((link) => link.snapshot === null), + requestSync, + { discard: true }, + ); const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( "PullRequestSyncReactor.start", @@ -434,12 +442,20 @@ export const make = Effect.gen(function* () { case "thread.pull-request-link-updated": return event.payload.snapshot === null ? requestSync(event.payload) : Effect.void; case "thread.pull-request-synced": - return Effect.forEach( - visibleThreadPullRequests(event.payload.pullRequests ?? []).filter( - (link) => link.snapshot === null, + return requestUnsyncedLinks(event.payload.pullRequests ?? []); + // Names only the synced link, so read the thread for siblings whose first sync failed. + case "thread.pull-request-link-synced": + return projections.getThreadsWithPullRequests(event.threadId).pipe( + Effect.flatMap((threads) => + Effect.forEach( + threads, + (thread) => requestUnsyncedLinks(thread.pullRequests ?? []), + { discard: true }, + ), + ), + Effect.catchCause( + logSkipped("pull request sibling sync skipped", { threadId: event.threadId }), ), - requestSync, - { discard: true }, ); // An agent can merge or close its pull request from a shell (`gh pr merge`), which // sends no merge notification. When a run that ran such a command ends, read the @@ -463,7 +479,7 @@ export const make = Effect.gen(function* () { } }).pipe(Effect.catchCause(logSkipped("pull request sync event stream failed", {}))), ); - yield* forkParked( + yield* forkBackground( Effect.gen(function* () { yield* worker.enqueue("all"); yield* worker.drain; diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 8cf9a4f28a439..11d146e183184 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1032,7 +1032,7 @@ it.effect("refreshes MCP credential liveness before calling the provider", () => }).pipe(Effect.provide(layerRunExecutionTest)), ); -it.effect("starts the provider when checkpoint baseline capture fails", () => +it.effect("starts the provider only after checkpoint baseline capture fails", () => Effect.gen(function* () { const threadId = ThreadId.make("thread:run-execution-baseline-failure"); const runId = RunId.make("run:run-execution-baseline-failure"); @@ -1047,6 +1047,7 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => id: CheckpointScopeId.make("checkpoint-scope:run-execution-baseline-failure"), } as OrchestrationV2CheckpointScope; const providerStarts = yield* Ref.make(0); + let baselineFinished = false; const writes = yield* Ref.make< ReadonlyArray<{ events: ReadonlyArray; @@ -1059,12 +1060,21 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => McpAppModelContext.layerEmpty, Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => - Effect.fail( - new CheckpointService.CheckpointBaselineCaptureError({ - scopeId: checkpointScope.id, - ordinalWithinScope: 0, - cause: new Error("VCS process timed out"), - }), + Effect.yieldNow.pipe( + Effect.andThen( + Effect.fail( + new CheckpointService.CheckpointBaselineCaptureError({ + scopeId: checkpointScope.id, + ordinalWithinScope: 0, + cause: new Error("VCS process timed out"), + }), + ), + ), + Effect.ensuring( + Effect.sync(() => { + baselineFinished = true; + }), + ), ), }), layerTurnItemPositionsTest, @@ -1090,10 +1100,18 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => commandId: CommandId.make("command:run-execution-baseline-failure"), appThread: { id: threadId } as OrchestrationV2AppThread, providerSessionId, - session: { - events: Stream.never, - startTurn: () => Ref.update(providerStarts, (count) => count + 1), - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, + session: new Proxy( + { + events: Stream.never, + startTurn: () => Ref.update(providerStarts, (count) => count + 1), + }, + { + get(target, key, receiver) { + assert.isTrue(baselineFinished); + return Reflect.get(target, key, receiver); + }, + }, + ) as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: runId, threadId, @@ -1152,7 +1170,15 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => }), ); -it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as const)( +const preparationFailureScenarios = [ + "failure", + "interruption", + "stale-attempt", + "start-guard", + "late-start-guard", + "baseline-interruption", +] as const; +it.effect.each(preparationFailureScenarios)( "handles %s before the provider turn starts", (scenario) => Effect.gen(function* () { @@ -1169,6 +1195,11 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co id: CheckpointScopeId.make("checkpoint-scope:run-execution-settings-failure"), } as OrchestrationV2CheckpointScope; const providerStarts = yield* Ref.make(0); + const startGuards = yield* Ref.make(0); + const reachesBaseline = + scenario === "start-guard" || + scenario === "late-start-guard" || + scenario === "baseline-interruption"; const refreshes = yield* Ref.make(0); const guardedWrites = yield* Ref.make(0); const writes = yield* Ref.make>>([]); @@ -1178,7 +1209,11 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co McpAppModelContext.layerEmpty, Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => - scenario === "start-guard" ? Effect.void : Effect.die("not reached"), + scenario === "baseline-interruption" + ? Effect.interrupt + : reachesBaseline + ? Effect.void + : Effect.die("not reached"), }), layerTurnItemPositionsTest, Layer.mock(EventSink.EventSinkV2)({ @@ -1200,7 +1235,7 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ ingestNormalized: () => Effect.succeed([]), }), - scenario === "start-guard" + reachesBaseline ? ServerSettings.layerTest() : Layer.mock(ServerSettings.ServerSettingsService)({ getSettings: @@ -1252,8 +1287,14 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co } as OrchestrationV2RunAttempt, attemptId, providerTurnOrdinal: 1, - ...(scenario === "start-guard" - ? { shouldStartProviderTurn: () => Effect.succeed(false) } + // A declined start is a normal exit, not a preparation failure. + ...(scenario === "start-guard" || scenario === "late-start-guard" + ? { + shouldStartProviderTurn: () => + Ref.updateAndGet(startGuards, (count) => count + 1).pipe( + Effect.map((count) => scenario === "late-start-guard" && count === 1), + ), + } : {}), message: { messageId: MessageId.make("message:run-execution-settings-failure"), @@ -1279,7 +1320,7 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co assert.equal(yield* Ref.get(providerStarts), 0); const events = (yield* Ref.get(writes)).flat(); - if (scenario === "interruption") { + if (scenario === "interruption" || scenario === "baseline-interruption") { assert.isTrue(Exit.isFailure(result)); if (Exit.isFailure(result)) assert.isTrue(Cause.hasInterruptsOnly(result.cause)); assert.equal(yield* Ref.get(guardedWrites), 0); @@ -1288,7 +1329,8 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co return; } assert.isTrue(Exit.isSuccess(result)); - if (scenario === "start-guard") { + if (scenario === "start-guard" || scenario === "late-start-guard") { + assert.equal(yield* Ref.get(startGuards), scenario === "late-start-guard" ? 2 : 1); assert.equal(yield* Ref.get(guardedWrites), 0); assert.equal(yield* Ref.get(refreshes), 0); assert.isEmpty(events); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 9abe812c3d125..9c547a5821e4a 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -860,6 +860,9 @@ export const layer: Layer.Layer< .responseStreamingMode, ), ); + // Nothing provider-side may start before the baseline exists: + // opening a Claude process already runs the user's SessionStart + // hooks and MCP servers, which can edit the workspace. yield* checkpointService .captureBaseline({ scope: input.checkpointScope, diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 052ef6b07e2dc..8d1fdeb7919f3 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -20,7 +20,7 @@ it.effect("refreshes workspace after checkpoint capture without reading history" const runId = RunId.make("run_finalize"); const scopeId = CheckpointScopeId.make("scope_finalize"); const capture = vi.fn(() => Effect.void); - const refresh = vi.fn(() => Effect.void); + const refresh = vi.fn(() => Effect.sync(() => assert.equal(capture.mock.calls.length, 1))); const checkpointContext = { runs: [], checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: "/repo" }], diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 6382a448cf3df..c57505449581c 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -27,7 +27,7 @@ import * as GitManager from "../git/GitManager.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; -import { forkParked } from "../serverActivation.ts"; +import { forkBackground, forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; class ThreadPullRequestServiceV2 extends Context.Service< @@ -390,7 +390,7 @@ export const make = Effect.gen(function* () { "ThreadPullRequestServiceV2.start", )(function* () { yield* forkParked(Stream.runForEach(orchestrator.streamDomainEvents, processEvent)); - yield* forkParked( + yield* forkBackground( Effect.gen(function* () { yield* worker.enqueue({ threadId: null, refresh: false, backfill: true }); yield* worker.drain; diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 9c2eceef512b5..12af97ff159fb 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -23,7 +23,7 @@ import * as GitManager from "../git/GitManager.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { forkParked } from "../serverActivation.ts"; +import { forkBackground, forkParked } from "../serverActivation.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; @@ -602,6 +602,7 @@ export const make = Effect.gen(function* () { return cleanUpSettledThread(event.threadId); case "thread.pull-request-link-updated": case "thread.pull-request-synced": + case "thread.pull-request-link-synced": case "provider-session.detached": return worker.enqueue(event.threadId); case "provider-session.updated": @@ -627,7 +628,7 @@ export const make = Effect.gen(function* () { const events = orchestrator.streamDomainEvents; const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); - yield* forkParked( + yield* forkBackground( Effect.gen(function* () { yield* worker.enqueue(undefined); yield* worker.drain; diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts index 9519b661cbf3f..3e128d7999ea4 100644 --- a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -279,6 +279,53 @@ function itemIds(projection: OrchestrationV2ThreadProjection): string[] { } it.layer(TestLayer)("compact bounded snapshot transport", (it) => { + it.effect("keeps compact visits current for legacy and opted-in thread subscribers", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const at = yield* DateTime.now; + const event: OrchestrationV2DomainEvent = { + id: EventId.make("event:compact-transport:visit"), + type: "thread.visit-recorded", + threadId: PARENT, + occurredAt: at, + payload: { lastVisitedAt: at }, + }; + yield* projections.apply(event); + const liveManagement = Layer.mock(ThreadManagementService.ThreadManagementService)({ + ensureLegacyTranscript: () => Effect.void, + getThreadSnapshot: (id) => projections.getThreadSnapshot(id).pipe(Effect.orDie), + getThreadSnapshotWindow: (id, options) => + projections.getThreadSnapshotWindow(id, options).pipe(Effect.orDie), + getThreadRecords: (id, fields, filter) => + projections.getThreadRecords(id, fields, filter).pipe(Effect.orDie), + streamStoredEventsFrom: () => Stream.make({ sequence: 1, commandId: null, event }), + }); + for (const acceptThreadFieldEvents of [false, true]) { + const items = yield* subscribeOrchestrationV2Thread({ + threadId: PARENT, + acceptThreadFieldEvents, + }).pipe( + Effect.flatMap((stream) => Stream.take(stream, 2).pipe(Stream.runCollect)), + Effect.provide(liveManagement), + ); + const live = items[1]; + if (live?.kind !== "event") return assert.fail("missing live event"); + const decoded = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(live)))); + if (decoded.kind !== "event") return assert.fail("missing decoded live event"); + assert.equal( + decoded.event.type, + acceptThreadFieldEvents ? "thread.visit-recorded" : "thread.visited", + ); + if ( + decoded.event.type === "thread.visited" || + decoded.event.type === "thread.visit-recorded" + ) { + assert.deepEqual(decoded.event.payload.lastVisitedAt, at); + } + } + }), + ); + describe.each([ ["fork", FORK], ["long run with a retained interrupt request", LONG], diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 1b6a7de8a944f..1b64acf6c7b38 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5416,7 +5416,7 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", WHERE command_id = ${"compact-pr-sync"} `; assert.equal(rows.length, 1); - assert.equal(rows[0]?.event_type, "thread.pull-request-link-updated"); + assert.equal(rows[0]?.event_type, "thread.pull-request-link-synced"); const payload = JSON.parse(rows[0]!.payload_json); assert.equal(payload.number, 2); assert.isUndefined(payload.pullRequests); @@ -5425,6 +5425,7 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", assert.deepEqual(after?.pullRequests?.[0], before?.pullRequests?.[0]); assert.deepEqual(after?.pullRequests?.[2], before?.pullRequests?.[2]); assert.equal(after?.pullRequests?.[1]?.stack?.id, "updated"); + assert.deepEqual(after?.updatedAt, before?.updatedAt); assert.isTrue((yield* maintenance.rebuild).valid); assert.deepEqual( (yield* orchestrator.getThreadShell(threadId))?.pullRequests, @@ -5458,7 +5459,7 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", yield* maintenance.compactEventStore; const linkEvents = yield* sql<{ command_id: string }>` SELECT command_id FROM orchestration_events - WHERE stream_id = ${threadId} AND event_type = 'thread.pull-request-link-updated' + WHERE stream_id = ${threadId} AND event_type = 'thread.pull-request-link-synced' ORDER BY sequence `; assert.deepEqual( @@ -5477,7 +5478,7 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", const threadEvents = () => sql<{ event_type: string }>` SELECT event_type FROM orchestration_events WHERE stream_id = ${threadId} - AND event_type IN ('thread.metadata-updated', 'thread.visited') + AND event_type IN ('thread.metadata-updated', 'thread.visit-recorded') ORDER BY sequence `; yield* orchestrator.dispatch({ @@ -5500,8 +5501,12 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", yield* maintenance.compactEventStore; assert.deepEqual( (yield* threadEvents()).map((row) => row.event_type), - ["thread.metadata-updated", "thread.visited"], + ["thread.metadata-updated", "thread.visit-recorded"], ); + const visited = yield* orchestrator.getThreadShell(threadId); + assert.equal(DateTime.formatIso(visited!.lastVisitedAt!), "2026-10-13T15:00:00.000Z"); + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual(yield* orchestrator.getThreadShell(threadId), visited); yield* orchestrator.dispatch({ type: "thread.metadata.update", commandId: CommandId.make("compact-pr-rename-again"), @@ -5513,6 +5518,9 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", (yield* threadEvents()).map((row) => row.event_type), ["thread.metadata-updated"], ); + const renamed = yield* orchestrator.getThreadShell(threadId); + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual(yield* orchestrator.getThreadShell(threadId), renamed); }), ); }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 2b4981e04d1b9..17d0114a648bc 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -68,6 +68,8 @@ import Migration0062 from "./Migrations/062_ThreadFailureLookupIndex.ts"; import Migration0063 from "./Migrations/063_DropUnusedV1Projections.ts"; import Migration0064 from "./Migrations/064_CanonicalizeThreadPullRequestLinks.ts"; +import Migration0065 from "./Migrations/065_ProjectionThreadSweepIndexes.ts"; + export const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], [2, "OrchestrationCommandReceipts", Migration0002], @@ -134,6 +136,7 @@ export const migrationEntries = [ [62, "ThreadFailureLookupIndex", Migration0062], [63, "DropUnusedV1Projections", Migration0063], [64, "CanonicalizeThreadPullRequestLinks", Migration0064], + [65, "ProjectionThreadSweepIndexes", Migration0065], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); @@ -178,6 +181,17 @@ const assertV2MigrationsCanRun = Effect.fn("assertV2MigrationsCanRun")(function* return []; const recordedName = recordedNames.get(id); if (recordedName === name) return []; + // Migration 65 repairs main's effects skipped by published perf migration ids. + if ( + (id === 62 && + (recordedName === "ProjectionThreadSweepIndexes" || + recordedName === "ProjectionThreadReadPayload")) || + (id === 63 && + (recordedName === "ProjectionThreadSweepIndexes" || + recordedName === "ProjectionThreadReadPayload")) || + (id === 64 && recordedName === "ProjectionThreadReadPayload") + ) + return []; return [`${id}_${name} (recorded: ${recordedName ?? "nothing"})`]; }); if (skipped.length === 0) return; @@ -231,5 +245,17 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ "Database migration history diverges from this build; recorded migration ids are skipped, not reconciled by name.", ).pipe(Effect.annotateLogs({ divergent })); } + // The migrator never runs an id at or below the latest recorded one, so a migration this + // build adds below another build's highest id never runs here. + const latestRecorded = Math.max(0, ...recorded.map((row) => row.migration_id)); + const recordedKeys = new Set(recorded.map((row) => `${row.migration_id}_${row.name}`)); + const skipped = migrationEntries.flatMap(([id, name]) => + id <= latestRecorded && !recordedKeys.has(`${id}_${name}`) ? [`${id}_${name}`] : [], + ); + if (skipped.length > 0) { + yield* Effect.logWarning( + "Migrations of this build never ran on this database: the migrator skips ids at or below its latest recorded migration.", + ).pipe(Effect.annotateLogs({ skipped })); + } 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 447c365082f27..8503bf8f5e429 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -1,9 +1,12 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; import * as SqlClient from "effect/sql/SqlClient"; import { migrationEntries, runMigrations } from "../Migrations.ts"; +import ProjectionThreadSweepIndexes from "./065_ProjectionThreadSweepIndexes.ts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); @@ -13,7 +16,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 64 }, (_, index) => index + 1), + Array.from({ length: 65 }, (_, index) => index + 1), ); }), ); @@ -36,6 +39,7 @@ layer("055_OrchestrationV2", (it) => { [62, "ThreadFailureLookupIndex"], [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], + [65, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -66,6 +70,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 62, name: "ThreadFailureLookupIndex" }, { migration_id: 63, name: "DropUnusedV1Projections" }, { migration_id: 64, name: "CanonicalizeThreadPullRequestLinks" }, + { migration_id: 65, name: "ProjectionThreadSweepIndexes" }, ]); const tables = yield* sql<{ readonly name: string }>` @@ -139,4 +144,61 @@ layer("055_OrchestrationV2", (it) => { ); }), ); + + it.effect.each([false, true])("repairs sweep indexes with legacy id 62: %s", (legacy) => { + const warnings: Array = []; + const logger = Logger.make(({ fiber, logLevel }) => { + const skipped = fiber.getRef(References.CurrentLogAnnotations).skipped; + if (logLevel === "Warn" && skipped !== undefined) warnings.push(skipped); + }); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 61 }); + if (legacy) { + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) + VALUES (62, 'ProjectionThreadSweepIndexes')`; + } else { + yield* runMigrations({ toMigrationInclusive: 62 }); + } + yield* sql`CREATE INDEX orchestration_v2_projection_threads_active_idx + ON orchestration_v2_projection_threads(updated_at, thread_id)`; + yield* sql`INSERT INTO orchestration_v2_projection_threads ( + thread_id, project_id, title, default_provider, provider_instance_id, + runtime_mode, interaction_mode, created_at, updated_at, payload_json + ) VALUES ('thread', 'project', 'Title', 'codex', 'codex', 'full-access', 'default', + '2026-07-24', '2026-07-24', '{"settledOverride":"settled","pullRequests":[{"number":1}]}')`; + assert.deepStrictEqual( + yield* runMigrations(), + migrationEntries.filter(([id]) => id > 62).map(([id, name]) => [id, name]), + ); + assert.deepStrictEqual(warnings, legacy ? [["62_ThreadFailureLookupIndex"]] : []); + yield* ProjectionThreadSweepIndexes; + assert.deepStrictEqual(yield* runMigrations(), []); + const sweep = sql`SELECT thread_id FROM orchestration_v2_projection_threads + INDEXED BY orchestration_v2_projection_threads_active_idx + WHERE deleted_at IS NULL AND archived_at IS NULL + AND json_extract(payload_json, '$.settledOverride') IS NULL + AND json_array_length(payload_json, '$.pullRequests') > 0`; + assert.deepStrictEqual(yield* sweep, []); + yield* sql`UPDATE orchestration_v2_projection_threads + SET payload_json = json_remove(payload_json, '$.settledOverride')`; + assert.deepStrictEqual(yield* sweep, [{ thread_id: "thread" }]); + yield* sql`UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, '$.pullRequests', json('[]'))`; + assert.deepStrictEqual(yield* sweep, []); + assert.deepStrictEqual( + yield* sql`SELECT turn_item_id FROM orchestration_v2_projection_turn_items + INDEXED BY orchestration_v2_projection_turn_items_failed_error_idx + WHERE type = 'error' AND status = 'failed'`, + [], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeSqliteClient.layer({ filename: ":memory:" }), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); }); diff --git a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts index 706cc02760eac..44c8b0f2a1485 100644 --- a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts +++ b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts @@ -11,7 +11,7 @@ import * as EventStore from "../../orchestration-v2/EventStore.ts"; import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import * as ProjectionMaintenance from "../../orchestration-v2/ProjectionMaintenance.ts"; import { listLinkedPullRequestThreads } from "../../pullRequest/linkedThreads.ts"; -import { runMigrations } from "../Migrations.ts"; +import { migrationManifest, runMigrations } from "../Migrations.ts"; const decodeThread = Schema.decodeSync(Schema.fromJsonString(OrchestrationV2AppThreadJson)); const updatedAt = "2026-09-01T00:00:00.000Z"; @@ -52,50 +52,57 @@ const thread = (id: string) => ({ deletedAt: null, }); -const layer = it.layer( - ProjectionMaintenance.layer.pipe( - Layer.provideMerge( - Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe( - Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), - ), +const fixtureLayer = ProjectionMaintenance.layer.pipe( + Layer.provideMerge( + Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe( + Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), ), ), ); +const layer = it.layer(fixtureLayer); layer("064_CanonicalizeThreadPullRequestLinks", (it) => { - it.effect("migrates single links once, preserves arrays, and keeps links after a rebuild", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 63 }); - const fixtures = [ - { ...thread("github"), linkedPullRequest: linked }, - { - ...thread("forgejo"), - linkedPullRequest: { ...linked, url: "http://forge.example:3000/acme/web/pulls/42" }, - }, - { - ...thread("azure"), - linkedPullRequest: { - ...linked, - repository: "project/_git/web", - url: "https://dev.azure.com/org/project/_git/web/pullrequest/42", + it.effect.each([false, true])( + "migrates links and preserves rebuilds with published perf ids: %s", + (published) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 62 }); + if (published) { + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES + (63, 'ProjectionThreadSweepIndexes'), (64, 'ProjectionThreadReadPayload')`; + } else { + yield* runMigrations({ toMigrationInclusive: 63 }); + } + const fixtures = [ + { ...thread("github"), linkedPullRequest: linked }, + { + ...thread("forgejo"), + linkedPullRequest: { ...linked, url: "http://forge.example:3000/acme/web/pulls/42" }, }, - }, - { ...thread("empty"), linkedPullRequest: linked, pullRequests: [] }, - { ...thread("current"), linkedPullRequest: linked, pullRequests: [currentLink] }, - { ...thread("unlinked"), linkedPullRequest: null }, - ...Array.from({ length: 501 }, (_, index) => ({ - ...thread(`page-${String(index).padStart(3, "0")}`), - linkedPullRequest: { - ...linked, - number: 9000, - url: "https://github.com/acme/web/pull/9000", + { + ...thread("azure"), + linkedPullRequest: { + ...linked, + repository: "project/_git/web", + url: "https://dev.azure.com/org/project/_git/web/pullrequest/42", + }, }, - })), - ]; - for (const fixture of fixtures) { - const payload = JSON.stringify(fixture); - yield* sql` + { ...thread("empty"), linkedPullRequest: linked, pullRequests: [] }, + { ...thread("current"), linkedPullRequest: linked, pullRequests: [currentLink] }, + { ...thread("unlinked"), linkedPullRequest: null }, + ...Array.from({ length: 501 }, (_, index) => ({ + ...thread(`page-${String(index).padStart(3, "0")}`), + linkedPullRequest: { + ...linked, + number: 9000, + url: "https://github.com/acme/web/pull/9000", + }, + })), + ]; + for (const fixture of fixtures) { + const payload = JSON.stringify(fixture); + yield* sql` INSERT INTO orchestration_v2_projection_threads ( thread_id, project_id, title, default_provider, runtime_mode, interaction_mode, created_at, updated_at, payload_json @@ -104,7 +111,7 @@ layer("064_CanonicalizeThreadPullRequestLinks", (it) => { ${updatedAt}, ${updatedAt}, ${payload} ) `; - yield* sql` + yield* sql` INSERT INTO orchestration_events ( event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json, application_event_version @@ -113,9 +120,9 @@ layer("064_CanonicalizeThreadPullRequestLinks", (it) => { 'user', ${payload}, '{}', 2 ) `; - } - const v1Payload = JSON.stringify({ linkedPullRequest: linked }); - yield* sql` + } + const v1Payload = JSON.stringify({ linkedPullRequest: linked }); + yield* sql` INSERT INTO orchestration_events ( event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json, application_event_version @@ -123,51 +130,62 @@ layer("064_CanonicalizeThreadPullRequestLinks", (it) => { 'user', ${v1Payload}, '{}', 1) `; - assert.deepEqual(yield* runMigrations(), [[64, "CanonicalizeThreadPullRequestLinks"]]); - assert.deepEqual(yield* runMigrations(), []); - const rows = yield* sql<{ readonly thread_id: string; readonly payload_json: string }>` + assert.deepEqual( + yield* runMigrations(), + migrationManifest.filter(([id]) => id > (published ? 64 : 63)), + ); + assert.deepEqual( + yield* sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('projection_thread_activities', 'projection_turns', 'projection_thread_proposed_plans', 'projection_pending_approvals', 'projection_state', 'checkpoint_diff_blobs')`, + [], + ); + assert.deepEqual(yield* runMigrations(), []); + const rows = yield* sql<{ readonly thread_id: string; readonly payload_json: string }>` SELECT thread_id, payload_json FROM orchestration_v2_projection_threads `; - const migrated = new Map(rows.map((row) => [row.thread_id, decodeThread(row.payload_json)])); - assert.deepEqual(migrated.get("github")?.pullRequests, [ - { ...currentLink, number: 42, url: linked.url, source: "manual" }, - ]); - assert.equal(migrated.get("forgejo")?.pullRequests?.[0]?.host, "forge.example:3000"); - assert.equal(migrated.get("azure")?.pullRequests?.[0]?.host, "dev.azure.com"); - assert.equal(migrated.get("azure")?.pullRequests?.[0]?.repository, "org/project/_git/web"); - assert.deepEqual(migrated.get("empty")?.pullRequests, []); - assert.deepEqual(migrated.get("current")?.pullRequests, [currentLink]); - assert.isUndefined(migrated.get("unlinked")?.pullRequests); - assert.equal(migrated.get("page-500")?.pullRequests?.[0]?.number, 9000); - assert.isTrue( - [...migrated.values()].every((value) => DateTime.formatIso(value.updatedAt) === updatedAt), - ); - const events = yield* sql<{ readonly stream_id: string; readonly payload_json: string }>` + const migrated = new Map( + rows.map((row) => [row.thread_id, decodeThread(row.payload_json)]), + ); + assert.deepEqual(migrated.get("github")?.pullRequests, [ + { ...currentLink, number: 42, url: linked.url, source: "manual" }, + ]); + assert.equal(migrated.get("forgejo")?.pullRequests?.[0]?.host, "forge.example:3000"); + assert.equal(migrated.get("azure")?.pullRequests?.[0]?.host, "dev.azure.com"); + assert.equal(migrated.get("azure")?.pullRequests?.[0]?.repository, "org/project/_git/web"); + assert.deepEqual(migrated.get("empty")?.pullRequests, []); + assert.deepEqual(migrated.get("current")?.pullRequests, [currentLink]); + assert.isUndefined(migrated.get("unlinked")?.pullRequests); + assert.equal(migrated.get("page-500")?.pullRequests?.[0]?.number, 9000); + assert.isTrue( + [...migrated.values()].every( + (value) => DateTime.formatIso(value.updatedAt) === updatedAt, + ), + ); + const events = yield* sql<{ readonly stream_id: string; readonly payload_json: string }>` SELECT stream_id, payload_json FROM orchestration_events WHERE application_event_version = 2 `; - assert.deepEqual( - new Map(events.map((event) => [event.stream_id, decodeThread(event.payload_json)])), - migrated, - ); - const [v1] = yield* sql<{ readonly payload_json: string }>` + assert.deepEqual( + new Map(events.map((event) => [event.stream_id, decodeThread(event.payload_json)])), + migrated, + ); + const [v1] = yield* sql<{ readonly payload_json: string }>` SELECT payload_json FROM orchestration_events WHERE event_id = 'event:v1' `; - assert.equal(v1?.payload_json, v1Payload); + assert.equal(v1?.payload_json, v1Payload); - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - assert.isTrue((yield* maintenance.rebuild).valid); - for (const [host, repository, id] of [ - ["github.com", "acme/web", "github"], - ["forge.example:3000", "acme/web", "forgejo"], - ["dev.azure.com", "org/project/_git/web", "azure"], - ] as const) { - assert.deepEqual( - (yield* listLinkedPullRequestThreads({ host, repository, number: 42 })).threads.map( - (value) => value.id, - ), - [ThreadId.make(id)], - ); - } - }), + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + assert.isTrue((yield* maintenance.rebuild).valid); + for (const [host, repository, id] of [ + ["github.com", "acme/web", "github"], + ["forge.example:3000", "acme/web", "forgejo"], + ["dev.azure.com", "org/project/_git/web", "azure"], + ] as const) { + assert.deepEqual( + (yield* listLinkedPullRequestThreads({ host, repository, number: 42 })).threads.map( + (value) => value.id, + ), + [ThreadId.make(id)], + ); + } + }).pipe(Effect.provide(Layer.fresh(fixtureLayer))), ); }); diff --git a/apps/server/src/persistence/Migrations/065_ProjectionThreadSweepIndexes.ts b/apps/server/src/persistence/Migrations/065_ProjectionThreadSweepIndexes.ts new file mode 100644 index 0000000000000..4ab462d73d994 --- /dev/null +++ b/apps/server/src/persistence/Migrations/065_ProjectionThreadSweepIndexes.ts @@ -0,0 +1,51 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; +import DropUnusedV1Projections from "./063_DropUnusedV1Projections.ts"; +import CanonicalizeThreadPullRequestLinks from "./064_CanonicalizeThreadPullRequestLinks.ts"; + +/** + * Background sweeps (settlement, pull request sync, the shell reads behind pull request + * discovery) filtered every thread with `json_extract` on its payload, which holds every linked + * pull request's snapshot: tens of KB parsed per thread per sweep. This index stores the values + * they filter on, so SQLite reads them from the index instead of parsing payloads. + * + * SQLite computes the entries from `payload_json` on every write, so they stay correct for any + * writer, including an older build sharing this database. ProjectionStore's sweep queries must + * spell each expression exactly as it is here, or SQLite parses the payload again. + */ +export default Effect.gen(function* () { + // Published perf builds used ids 63/64, so the migrator skips main's effects there. + yield* DropUnusedV1Projections; + yield* CanonicalizeThreadPullRequestLinks; + const sql = yield* SqlClient.SqlClient; + // Development builds of this change created an index of the same name over copied columns. + // Replace it so every database gets this definition. + yield* sql`DROP INDEX IF EXISTS orchestration_v2_projection_threads_active_idx`; + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_active_idx + ON orchestration_v2_projection_threads( + updated_at, + thread_id, + json_extract(payload_json, '$.settledAt'), + json_extract(payload_json, '$.settledOverride'), + json_extract(payload_json, '$.pinnedAt'), + json_extract(payload_json, '$.autoSettleDisabledAt'), + json_array_length(payload_json, '$.pullRequests'), + CASE + WHEN json_extract(payload_json, '$.forkedFrom.type') = 'run' + THEN json_extract(payload_json, '$.forkedFrom.threadId') + ELSE NULL + END, + provider_instance_id + ) + WHERE deleted_at IS NULL AND archived_at IS NULL + `; + // Older sweep builds recorded id 62, so they skip main's failed-error index migration. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_failed_error_idx + ON orchestration_v2_projection_turn_items( + thread_id, run_id, node_id, updated_at DESC, ordinal DESC, turn_item_id DESC + ) + WHERE type = 'error' AND status = 'failed' + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 802c5110490ac..bcd1cc2493535 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -44,6 +44,7 @@ describe("V2 preview upgrade", () => { [62, "ThreadFailureLookupIndex"], [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], + [65, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -131,6 +132,7 @@ describe("V2 preview upgrade", () => { [62, "ThreadFailureLookupIndex"], [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], + [65, "ProjectionThreadSweepIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 668e7f7469008..5c473240015b9 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -428,23 +428,18 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { - const available: EditorId[] = []; - - for (const editor of EDITORS) { - if (editor.commands === null) { - if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { - available.push(editor.id); - } - continue; - } - - const command = yield* resolveEditorCommand(editor, env); - if (Option.isSome(command)) { - available.push(editor.id); - } - } - - return available; + // Independent probes share PATH listings and retain EDITORS order. + const found = yield* Effect.forEach( + EDITORS, + (editor) => + editor.commands === null + ? resolveUsableFileManagerCommand(platform, env).pipe( + Effect.map((command) => command !== undefined), + ) + : resolveEditorCommand(editor, env).pipe(Effect.map(Option.isSome)), + { concurrency: "unbounded" }, + ); + return EDITORS.flatMap((editor, index) => (found[index] ? [editor.id] : [])); }); const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 5c164f75d8520..0a658a707d266 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -8,6 +8,7 @@ import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerActivation from "../serverActivation.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; @@ -19,6 +20,7 @@ export const layer = Layer.effect( const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; + const backgroundStart = yield* ServerActivation.ServerBackgroundStart; return ProviderHost.ProviderHost.of({ paths: { cwd: config.cwd, @@ -34,6 +36,9 @@ export const layer = Layer.effect( subscribe: serverSettings.subscribeChanges, }, shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork, + ...(backgroundStart === undefined + ? {} + : { awaitStartupProbe: backgroundStart.awaitFirstClient }), resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }), credentials: (namespace, bindingId) => diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 16860c78ee9a2..def5ca27c3569 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -21,7 +21,7 @@ import * as Persistable from "effect/persistence/Persistable"; import * as PersistedCache from "effect/persistence/PersistedCache"; import * as Persistence from "effect/persistence/Persistence"; import * as ServerConfig from "../config.ts"; -import { forkParked } from "../serverActivation.ts"; +import { forkBackground } from "../serverActivation.ts"; const CONCURRENT_READS = 512; // Persistence prefixes every entry key with the store id, so each entry file @@ -216,7 +216,7 @@ export const layer = Layer.unwrap( Layer.provide( KeyValueStore.layerFileSystem(directory).pipe( Layer.tap(() => - forkParked( + forkBackground( pruneExpiredEntryFiles(directory).pipe( Effect.catch((cause) => Effect.logWarning("Failed to prune PR cache files", { cause }), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index fb21aa6e70dd4..ae27c061df263 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -93,6 +93,7 @@ export function shouldPublishAgentAwarenessEvent( case "thread.metadata-updated": case "thread.pull-request-link-updated": case "thread.pull-request-synced": + case "thread.pull-request-link-synced": case "thread.model-selection-updated": case "thread.provider-switched": case "run.created": @@ -111,6 +112,7 @@ export function shouldPublishAgentAwarenessEvent( case "thread.pin-reordered": case "thread.active-reordered": case "thread.visited": + case "thread.visit-recorded": case "thread.marked-unread": case "thread.runtime-mode-updated": case "thread.interaction-mode-updated": diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 59daedc151071..b3d7870b5dcd0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -30,7 +30,10 @@ import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; import * as ServerConfig from "./config.ts"; import { withUntracedRequests } from "./http.ts"; import * as ServerHttp from "./http.ts"; -import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; +import { + guardHttpResponseWriteErrors, + holdRequestsUntilServing, +} from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; import * as Ws from "./ws.ts"; import * as AgentScopeLive from "./process/agentScope.ts"; @@ -287,7 +290,9 @@ const layerRelayClient = Layer.unwrap( const layerHttpServer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - return NodeHttpServer.layer(() => guardHttpResponseWriteErrors(NodeHttp.createServer()), { + const createServer = () => + holdRequestsUntilServing(guardHttpResponseWriteErrors(NodeHttp.createServer())); + return NodeHttpServer.layer(createServer, { host: config.host ?? "127.0.0.1", port: config.port, gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, @@ -720,7 +725,13 @@ const layerMakeServer = Layer.unwrap( const ownership = yield* ServerOwnership.acquireServerOwnership(config.serverRuntimeStatePath); const activation = yield* Deferred.make(); const awaitActivation = Deferred.await(activation); - const layerActivation = Layer.succeed(ServerActivation.ServerActivation, awaitActivation); + const layerActivation = Layer.merge( + Layer.succeed(ServerActivation.ServerActivation, awaitActivation), + Layer.effect( + ServerActivation.ServerBackgroundStart, + ServerActivation.makeBackgroundStart(awaitActivation), + ), + ); const runtimeStateParked = yield* Deferred.make(); const tailscaleParked = yield* Deferred.make(); const cloudLinkParked = yield* Deferred.make(); diff --git a/apps/server/src/serverActivation.test.ts b/apps/server/src/serverActivation.test.ts index dc41b884ab36c..df76eb5aac553 100644 --- a/apps/server/src/serverActivation.test.ts +++ b/apps/server/src/serverActivation.test.ts @@ -1,6 +1,8 @@ import { expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { TestClock } from "effect/testing"; import * as ServerActivation from "./serverActivation.ts"; @@ -21,3 +23,33 @@ it.effect("proves a root is parked before returning and releases it with one gat }), ), ); + +it.effect("defers maintenance until the client loads and startup is quiet", () => + Effect.gen(function* () { + const activation = yield* Deferred.make(); + const firstRan = yield* Deferred.make(); + const background = yield* ServerActivation.makeBackgroundStart(Deferred.await(activation)); + yield* ServerActivation.forkBackground(Deferred.succeed(firstRan, undefined)).pipe( + Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), + Effect.provideService(ServerActivation.ServerBackgroundStart, background), + ); + yield* Deferred.succeed(activation, undefined); + yield* TestClock.adjust("10 seconds"); + expect(yield* Deferred.isDone(firstRan)).toBe(false); + yield* background.markClientServed; + yield* Deferred.await(firstRan); + }), +); + +it.effect("eventually starts probes and maintenance even when no client connects", () => + Effect.gen(function* () { + const background = yield* ServerActivation.makeBackgroundStart(Effect.void); + const firstClient = yield* background.awaitFirstClient.pipe(Effect.forkScoped); + const maintenance = yield* background.awaitTurn.pipe(Effect.forkScoped); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(firstClient); + expect(maintenance.pollUnsafe()).toBeUndefined(); + yield* TestClock.adjust("20 seconds"); + yield* Fiber.join(maintenance); + }), +); diff --git a/apps/server/src/serverActivation.ts b/apps/server/src/serverActivation.ts index c43cd259ef66f..f60f780b63315 100644 --- a/apps/server/src/serverActivation.ts +++ b/apps/server/src/serverActivation.ts @@ -1,7 +1,7 @@ import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import type * as Fiber from "effect/Fiber"; +import * as Fiber from "effect/Fiber"; import type * as Scope from "effect/Scope"; export class ServerActivation extends Context.Reference | undefined>( @@ -28,3 +28,52 @@ export const forkParkedFiber = ( export const forkParked = ( effect: Effect.Effect, ): Effect.Effect => forkParkedFiber(effect).pipe(Effect.asVoid); + +export interface BackgroundStart { + readonly awaitFirstClient: Effect.Effect; + readonly awaitTurn: Effect.Effect; + readonly markClientServed: Effect.Effect; +} + +export class ServerBackgroundStart extends Context.Reference( + "t3/serverBackgroundStart", + { defaultValue: () => undefined }, +) {} + +// Probes wait for config delivery (at most 10s). Maintenance also waits for a +// quiet startup (10s, at most 30s). +export const makeBackgroundStart = (activation: Effect.Effect) => + Effect.gen(function* () { + const clientServed = yield* Deferred.make(); + const served = Deferred.await(clientServed); + const firstClient = yield* activation.pipe( + Effect.andThen(served.pipe(Effect.timeoutOption("10 seconds"))), + Effect.forkScoped, + ); + const quiet = yield* activation.pipe( + Effect.andThen( + Effect.all([Effect.sleep("10 seconds"), served], { concurrency: "unbounded" }).pipe( + Effect.timeoutOption("30 seconds"), + ), + ), + Effect.forkScoped, + ); + return { + awaitFirstClient: Fiber.join(firstClient).pipe(Effect.asVoid), + awaitTurn: Fiber.join(quiet).pipe(Effect.asVoid), + markClientServed: Deferred.succeed(clientServed, undefined).pipe(Effect.asVoid), + } satisfies BackgroundStart; + }); + +export const awaitBackgroundTurn = Effect.gen(function* () { + const gate = yield* ServerBackgroundStart; + if (gate !== undefined) yield* gate.awaitTurn; +}); + +export const markClientServed = Effect.gen(function* () { + const gate = yield* ServerBackgroundStart; + if (gate !== undefined) yield* gate.markClientServed; +}); + +export const forkBackground = (effect: Effect.Effect) => + forkParked(Effect.andThen(awaitBackgroundTurn, effect)); diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index a919f6720fbfa..60c2e4f9d565c 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -1,6 +1,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as Deferred from "effect/Deferred"; @@ -170,6 +171,8 @@ it.effect("parks automatic pull until activation without delaying command readin address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 3773), }), Path.layer, + // The boot heartbeat counts threads; an empty database is enough here. + NodeSqliteClient.layer({ filename: ":memory:" }).pipe(Layer.orDie), ); yield* Effect.gen(function* () { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index c8e012301293d..8ab9d007a2a44 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -43,7 +43,7 @@ import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; -import { forkParked, forkParkedFiber } from "./serverActivation.ts"; +import { forkBackground, forkParked, forkParkedFiber } from "./serverActivation.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -374,11 +374,11 @@ export const maybeOpenBrowser = (target: string) => ); }); +// Phase and server fields go on the startup spans only. As inherited span +// annotations they were copied onto every span of the workers forked during +// startup (the effect worker, PR sync), which is most of the trace file. const runStartupPhase = (phase: string, effect: Effect.Effect) => effect.pipe( - // Attributes on the span itself, not annotateSpans: an annotation follows - // every fiber forked inside the phase, such as the effect worker, for the - // life of the server. Effect.withSpan(`server.startup.${phase}`, { attributes: { "startup.phase": phase } }), ); @@ -608,15 +608,16 @@ const make = (options?: StartupOptions) => : importPendingTranscripts ).pipe(forkParked); + yield* forkBackground( + recordStartupHeartbeat.pipe( + Effect.withSpan("server.startup.heartbeat.record", { + attributes: { "startup.phase": "heartbeat.record" }, + }), + Effect.ignoreCause({ log: true }), + ), + ); yield* forkParked( Effect.gen(function* () { - yield* Effect.logDebug("startup phase: recording startup heartbeat"); - yield* recordStartupHeartbeat.pipe( - Effect.withSpan("server.startup.heartbeat.record", { - attributes: { "startup.phase": "heartbeat.record" }, - }), - Effect.ignoreCause({ log: true }), - ); if (serverConfig.startupPresentation === "headless") { yield* Effect.logDebug("startup phase: headless access info"); const accessInfo = yield* issueHeadlessServeAccessInfo(); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d684448674282..86744d5805a5e 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -40,7 +40,7 @@ import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementService.ts"; -import { forkParked } from "./serverActivation.ts"; +import { forkBackground, forkParked } from "./serverActivation.ts"; import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; @@ -1046,7 +1046,7 @@ const make = Effect.gen(function* () { const changes = yield* settingsService.subscribeChanges; const events = engine.streamDomainEvents; let lastSettings = yield* settingsService.getSettings.pipe(Effect.orDie); - yield* forkParked( + yield* forkBackground( requestSweep.pipe( Effect.andThen(worker.drain), Effect.repeat(Schedule.spaced("1 hour")), diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 59542498264f8..f7242bfa5a8ef 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -24,6 +24,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; +import { awaitBackgroundTurn } from "../serverActivation.ts"; import { getTelemetryIdentifier } from "./Identify.ts"; interface BufferedAnalyticsEvent { @@ -284,9 +285,15 @@ export const make = Effect.gen(function* () { }, ); - yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.flatMap(() => flushWhenDue)), { - disableYield: true, - }).pipe(Effect.forkScoped); + // A starting server sends its first batch once startup work may run. + yield* awaitBackgroundTurn.pipe( + Effect.andThen( + Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.flatMap(() => flushWhenDue)), { + disableYield: true, + }), + ), + Effect.forkScoped, + ); yield* Effect.addFinalizer(() => flush); diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 3c2b62259bff5..319bfeb915e9d 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -117,6 +117,34 @@ const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( return { git, checkpointRef }; }); +it.effect( + "checkpoint capture preserves a linked worktree's common directory containing a newline", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-newline-" }); + const cwd = path.join(root, "main\nrepository"); + const worktree = path.join(root, "linked"); + yield* fileSystem.makeDirectory(cwd); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* git(["worktree", "add", "--detach", worktree, "HEAD"]); + yield* fileSystem.writeFileString(path.join(worktree, "file.txt"), "linked worktree\n"); + + yield* driver.checkpoints.captureCheckpoint({ cwd: worktree, checkpointRef }); + + assert.strictEqual( + (yield* git(["show", `${checkpointRef}:file.txt`])).stdout, + "linked worktree\n", + ); + assert.strictEqual( + yield* fileSystem.readFileString(path.join(cwd, "file.txt")), + "unstaged\n", + ); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect("checkpoint capture skips untracked nested repositories without a commit", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ade826e53f38b..716c6ddd6bc7b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -172,6 +172,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; +import * as ServerActivation from "./serverActivation.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -741,6 +742,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; readonly acceptCompactTurnItems?: boolean; + readonly acceptThreadFieldEvents?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -766,11 +768,37 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs afterSequence, }) .pipe( - Stream.map((stored) => ({ - kind: "event" as const, - sequence: stored.sequence, - event: projectDomainEventForWire(stored.event), - })), + Stream.mapEffect((stored) => + Effect.gen(function* () { + const event = stored.event; + if ( + input.acceptThreadFieldEvents !== true && + (event.type === "thread.pull-request-link-synced" || + event.type === "thread.visit-recorded") + ) { + // Older clients need the legacy full-thread event. The live stream reads + // committed metadata only; historical compact events use a fresh snapshot below. + const { thread } = yield* threadManagement.getThreadRecords(input.threadId, []); + return { + kind: "event" as const, + sequence: stored.sequence, + event: projectDomainEventForWire({ + ...event, + type: + event.type === "thread.visit-recorded" + ? "thread.visited" + : "thread.pull-request-synced", + payload: thread, + }), + }; + } + return { + kind: "event" as const, + sequence: stored.sequence, + event: projectDomainEventForWire(event), + }; + }), + ), coalesceThreadLiveStream, Stream.mapError( (cause) => @@ -911,6 +939,16 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs if (shell !== null) return yield* snapshotThenLive(); } const replay = yield* loadReplayThrough(input.afterSequence, highWater); + if ( + input.acceptThreadFieldEvents !== true && + replay.some( + (item) => + item.event.type === "thread.pull-request-link-synced" || + item.event.type === "thread.visit-recorded", + ) + ) { + return yield* snapshotThenLive(); + } const plan = decideThreadResume({ afterSequence: input.afterSequence, highWater, @@ -2916,6 +2954,7 @@ const layerWsRpc = ( Effect.gen(function* () { const usageLimitsCommand = input.usageLimitsCommand === true; const config = yield* loadServerConfig({ usageLimitsCommand }); + yield* ServerActivation.markClientServed; const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ version: 1 as const, diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 351b819b6615c..823cb98f2e46a 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -7,7 +7,7 @@ import { FILL_PREVIEW_VIEWPORT, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { type ComponentProps, useEffect, useEffectEvent, useState } from "react"; +import { type ComponentProps, lazy, Suspense, useEffect, useEffectEvent, useState } from "react"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; @@ -21,7 +21,6 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserDefaults } from "./browserDefaults"; import { useBrowserPointerStore } from "./browserPointerStore"; -import { HostedBrowserWebview } from "./HostedBrowserWebview"; import { openUrlInPreview } from "./openFileInPreview"; import { rendersServerTabNatively } from "./previewRuntime"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; @@ -140,13 +139,22 @@ export function ElectronBrowserHost() { ); } +// Only Electron with an open preview mounts a webview; the web build never loads it. +const HostedBrowserWebview = lazy(() => + import("./HostedBrowserWebview").then((module) => ({ default: module.HostedBrowserWebview })), +); + function AuthorizedBrowserWebview(props: ComponentProps) { const canOperatePreview = useEnvironmentScope( props.threadRef.environmentId, AuthPreviewOperateScope, ); const profileId = useTabProfileId(props.profileId); - return canOperatePreview ? : null; + return canOperatePreview ? ( + + + + ) : null; } /** diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index eac41c7eb0be1..65d242eb5a1e3 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -1,9 +1,10 @@ +import { loadRehypeRaw } from "@t3tools/shared/markdownPipeline"; import { EnvironmentId } from "@t3tools/contracts"; import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; import { act, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { MarkdownFindContext } from "./chat/markdownFindContext"; @@ -46,6 +47,8 @@ import ChatMarkdown from "./ChatMarkdown"; const roots: Root[] = []; +beforeAll(() => loadRehypeRaw()); + beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); }); @@ -769,7 +772,7 @@ describe("ChatMarkdown thread find", () => { await show("Needle"); await frame(); - expect(diagrams()).toBe(2); + await vi.waitFor(() => expect(diagrams()).toBe(2)); await show("Alpha"); await frame(); await frame(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a608ecb0298b5..1a4a60db2696f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -17,7 +17,10 @@ import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools import { CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, - CHAT_MARKDOWN_REHYPE_PLUGINS, + chatMarkdownRehypePlugins, + loadedRehypeRaw, + loadRehypeRaw, + markdownMayContainRawHtml, } from "@t3tools/shared/markdownPipeline"; import { useAtomValue } from "@effect/atom-react"; import { @@ -80,6 +83,7 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import React, { Children, + lazy, Suspense, type CSSProperties, type ComponentProps, @@ -158,7 +162,6 @@ import { GitHubIcon } from "./Icons"; import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; -import { MermaidDiagram } from "./chat/MermaidDiagram"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { @@ -1839,10 +1842,49 @@ function rehypeHeadingIds() { // Heading ids are added after sanitizing, which would prefix them a second time. const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ - ...CHAT_MARKDOWN_REHYPE_PLUGINS, + ...chatMarkdownRehypePlugins(null), rehypeHeadingIds, ] satisfies NonNullable; +type RehypePlugins = NonNullable; +let rawHtmlRehypePlugins: RehypePlugins | null = null; +let rawHtmlRehypePluginsPromise: Promise | null = null; + +function withRawHtml(rehypeRaw: NonNullable>) { + rawHtmlRehypePlugins ??= [...chatMarkdownRehypePlugins(rehypeRaw), rehypeHeadingIds]; + return rawHtmlRehypePlugins; +} + +// rehype-raw brings parse5, so it loads with the first message that has a tag. +// After a failed load, messages keep rendering without their HTML: `use()` needs +// this same settled promise on every render, and the failed import stays failed. +function loadRawHtmlRehypePlugins(): Promise { + rawHtmlRehypePluginsPromise ??= loadRehypeRaw().then( + withRawHtml, + () => CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS, + ); + return rawHtmlRehypePluginsPromise; +} + +function ChatMarkdownContent({ + text, + parseRawHtml, + render, +}: { + text: string; + parseRawHtml: boolean; + render: (rehypePlugins: RehypePlugins) => ReactNode; +}) { + if (!parseRawHtml) return render(CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS); + if (!markdownMayContainRawHtml(text)) return render(CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS); + const rehypeRaw = loadedRehypeRaw(); + return render(rehypeRaw ? withRawHtml(rehypeRaw) : use(loadRawHtmlRehypePlugins())); +} + +const MermaidDiagram = lazy(() => + import("./chat/MermaidDiagram").then((module) => ({ default: module.MermaidDiagram })), +); + const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< ReactMarkdownOptions["rehypePlugins"] >; @@ -3350,6 +3392,22 @@ function ChatMarkdown({ ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), ]; + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. + // Keep that behavior explicit because literal mode depends on escaping the + // complete source token instead of dropping it from the rendered message. + const renderMarkdown = (rehypePlugins: RehypePlugins) => ( + + + {text} + + + ); return (
- - - {text} - - + {/* While rehype-raw loads, the message renders with its raw HTML left out. */} + + + {localMediaPreview ? ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 063e9ecd086ee..b015e09d27c45 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -145,6 +145,8 @@ import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; import { + lazy, + Suspense, Fragment, type SetStateAction, useEffect, @@ -241,7 +243,6 @@ import { BrowserSettingsReadError, openUrlInPreview } from "../browser/openFileI import { resolveDiscoveredServerUrl } from "../browser/browserTargetResolver"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { addBrowserSurface } from "./preview/addBrowserSurface"; -import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { usePreviewSession } from "./preview/usePreviewSession"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; @@ -636,6 +637,11 @@ function useDraftHeroLayoutTransition( return { transitionGroupRef, composerAnchorRef, captureComposerRect } as const; } +const ThreadPreviewMiniPlayer = lazy(() => + import("./preview/ThreadPreviewMiniPlayer").then((module) => ({ + default: module.ThreadPreviewMiniPlayer, + })), +); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; @@ -4003,6 +4009,7 @@ export default function ChatView(props: ChatViewProps) { // re-pins on its own (independent of the refs), so the timeline needs a // render-visible flag to switch it off once the user scrolls away. const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); + const isTimelineFollowingEnd = () => timelineScrollModeRef.current === "following-end"; const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); @@ -8048,6 +8055,7 @@ export default function ChatView(props: ChatViewProps) { onAnchorSizeChanged={onTimelineAnchorSizeChangedStable} contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} + isFollowingEnd={isTimelineFollowingEnd} onIsAtEndChange={onIsAtEndChangeStable} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEndStable} @@ -8410,11 +8418,13 @@ export default function ChatView(props: ChatViewProps) { activePreviewMiniPlayer && previewMiniPlayerVisible && (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? ( - + + + ) : null} { }); describe("buildThreadActionItems", () => { + it("keeps equal thread ids in different environments distinct and navigable", async () => { + const local = makeThread(); + const remote = makeThread({ id: local.id, environmentId: EnvironmentId.make("remote") }); + const runThread = vi.fn(async () => {}); + const items = buildThreadActionItems({ + threads: [local, remote], + projectTitleById: new Map(), + sortOrder: "updated_at", + icon: null, + runThread, + }); + expect(new Set(items.map((item) => item.value)).size).toBe(2); + for (const item of items) await item.run(); + expect(runThread.mock.calls).toEqual([[local], [remote]]); + }); + it("orders threads by most recent activity and formats timestamps from updatedAt", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z")); @@ -393,8 +409,8 @@ describe("buildThreadActionItems", () => { }); expect(items.map((item) => item.value)).toEqual([ - "thread:thread-older", - "thread:thread-newer", + "thread:environment-local:thread-older", + "thread:environment-local:thread-newer", ]); expect(items[0]?.timestamp).toBe("1d ago"); expect(items[1]?.timestamp).toBe("5d ago"); @@ -435,8 +451,8 @@ describe("buildThreadActionItems", () => { expect(groups).toHaveLength(1); expect(groups[0]?.value).toBe("threads-search"); expect(groups[0]?.items.map((item) => item.value)).toEqual([ - "thread:thread-title-match", - "thread:thread-context-match", + "thread:environment-local:thread-title-match", + "thread:environment-local:thread-context-match", ]); }); @@ -482,9 +498,9 @@ describe("buildThreadActionItems", () => { }); expect(groups[0]?.items.map((item) => item.value)).toEqual([ - "thread:recent-title", - "thread:old-prefix", - "thread:recent-content", + "thread:environment-local:recent-title", + "thread:environment-local:old-prefix", + "thread:environment-local:recent-content", ]); }); @@ -661,8 +677,8 @@ describe("buildThreadActionItems", () => { }); expect(groups.flatMap((group) => group.items)).toEqual([ - expect.objectContaining({ value: `thread:${titleThread.id}` }), - expect.objectContaining({ value: `thread:${idThread.id}` }), + expect.objectContaining({ value: `thread:${titleThread.environmentId}:${titleThread.id}` }), + expect.objectContaining({ value: `thread:${idThread.environmentId}:${idThread.id}` }), ]); }); @@ -702,7 +718,7 @@ describe("buildThreadActionItems", () => { runThread: async (_thread) => undefined, }); - expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); + expect(items.map((item) => item.value)).toEqual(["thread:environment-local:thread-active"]); }); }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 729069cdfa297..7cd6ac8912138 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -336,7 +336,7 @@ export function buildThreadActionItems import("./ThreadTerminalDrawer")); +function ThreadTerminalDrawer(props: ComponentProps) { + // Keep existing terminal sessions mounted when hidden, but an empty closed drawer + // has no terminal to render or synchronize and need not load its renderer. + if (props.visible === false && props.terminalIds.length === 0) return null; + return ( + + + + ); +} /** Where a drawer or panel opens new terminals when a script run overrides the thread's own. */ export type PersistentTerminalLaunchContext = { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 81eb57b37ddc3..70aeff5377d88 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -161,6 +161,7 @@ import { import { readThreadShell, useAllEnvironmentProjectSnapshotsReady, + useAllEnvironmentShellsBootstrapped, useProjects, useServerConfigs, useThreadShells, @@ -2699,6 +2700,8 @@ export default function Sidebar() { projectGroupByScopeKey, projectScopeItems, } = useSidebarLookups(); + // An empty shell before boot finishes is still loading. + const shellsBootstrapped = useAllEnvironmentShellsBootstrapped(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -5048,7 +5051,8 @@ export default function Sidebar() { ) : null} - {!isSearchingThreads && + {shellsBootstrapped && + !isSearchingThreads && visibleDraftSessionCount === 0 && pinnedThreads.length + activeThreads.length + diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 5c6e62d114fcc..bf83b40a0d34d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1,3 +1,4 @@ +import { loadRehypeRaw } from "@t3tools/shared/markdownPipeline"; import { RegistryProvider } from "@effect/atom-react"; import { ApprovalRequestId, @@ -10,7 +11,7 @@ import { import type { LegendListRef } from "@legendapp/list/react"; import { act, createRef, Profiler, useState, type ComponentProps, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; import "~/index.css"; @@ -147,6 +148,8 @@ function longText(tail = "deep hidden detail only after expand") { const roots: Root[] = []; let container: HTMLDivElement; +beforeAll(() => loadRehypeRaw()); + beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 66c04d58f9633..c857d75a082a5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -74,7 +74,9 @@ import { claudeSkillInvocation, isJavaScriptReplTool } from "@t3tools/shared/too import { observeVisibleAnimation } from "../../lib/visibleAnimation"; import { createContext, + lazy, memo, + Suspense, use, useContext, useEffect, @@ -90,8 +92,6 @@ import { type LegendListRef, type MaintainScrollAtEndOptions, } from "@legendapp/list/react"; -import { FileDiff } from "@pierre/diffs/react"; -import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { type TimelineEntry, providerErrorPresentation, @@ -111,12 +111,6 @@ import { isVideoAttachment, type TurnDiffSummary, } from "../../types"; -import { - getRenderablePatch, - resolveDiffThemeName, - resolveFileDiffPath, -} from "../../lib/diffRendering"; -import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -179,7 +173,6 @@ import { } from "./SnapShotAttachmentDetails"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { HtmlRenderFrame } from "./HtmlRenderFrame"; -import { McpAppFrame } from "./McpAppFrame"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { useFileContextMenuHandler } from "../../fileContextMenu"; import { useProject, useThreadShell } from "../../state/entities"; @@ -305,11 +298,7 @@ import { SkillChipIcon, SkillInlineText } from "./SkillInlineText"; import * as DateTime from "effect/DateTime"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { forwardWheelToTimeline } from "./timelineMinimapWheel"; -import { - buildReviewCommentRenderablePatch, - formatReviewCommentFence, - type ReviewCommentContext, -} from "../../reviewCommentContext"; +import { formatReviewCommentFence, type ReviewCommentContext } from "../../reviewCommentContext"; interface TimelineRowSharedState { citationRequest: AssistantCitationTarget | null; @@ -513,6 +502,11 @@ interface MessagesTimelineProps { * the user drifts near the bottom. */ liveFollowEnabled: boolean; + /** + * Whether the reader follows the live edge, as opposed to reading a new + * turn anchored at the top or history. Read when the scroll position is remembered. + */ + isFollowingEnd?: () => boolean; onContentOverflowChange?: (overflows: boolean) => void; onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; @@ -605,6 +599,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ onIsAtEndChange, onContentOverflowChange, liveFollowEnabled, + isFollowingEnd, onToolOutputCollapsedAtEnd, onManualNavigation, cancelPositionRestoreRef, @@ -1123,11 +1118,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ workGroupState: workGroupViewState, }, }, - liveFollowEnabled && !anchoredEndSpace, + liveFollowEnabled && (isFollowingEnd?.() ?? !anchoredEndSpace), ); } } - if (isAtEnd !== undefined && !citationPositioning) { + if (isAtEnd !== undefined && !citationPositioning && !paintOnly) { onIsAtEndChange(isAtEnd); } reportContentOverflow(); @@ -2849,24 +2844,31 @@ function HtmlRenderTimelineRow({ row }: { row: Extract + import("./McpAppFrame").then((module) => ({ default: module.McpAppFrame })), +); + function McpAppTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const { awaitingUser } = use(TimelineRowActivityCtx); return (
- ctx.onAppFullscreenChange(row.id, fullscreen)} - /> + + ctx.onAppFullscreenChange(row.id, fullscreen)} + /> +
); } @@ -4638,13 +4640,14 @@ const UserMessageBody = memo(function UserMessageBody(props: { ); }); +// The diff renderer (with shiki) loads only for messages that quote a review comment. +const ReviewCommentDiff = lazy(() => + import("./ReviewCommentDiff").then((module) => ({ default: module.ReviewCommentDiff })), +); + function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) { const ctx = use(TimelineRowCtx); const fenceLanguage = comment.fenceLanguage ?? "diff"; - const renderablePatch = getRenderablePatch( - buildReviewCommentRenderablePatch(comment), - `review-comment:${comment.id}`, - ); return (
@@ -4670,27 +4673,9 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte className="text-foreground" /> )} - {renderablePatch?.kind === "files" && ( - - {renderablePatch.files.map((fileDiff) => ( - - ))} - - )} - {renderablePatch?.kind === "raw" && ( -
-          {renderablePatch.text}
-        
- )} + + +
); } diff --git a/apps/web/src/components/chat/ReviewCommentDiff.tsx b/apps/web/src/components/chat/ReviewCommentDiff.tsx new file mode 100644 index 0000000000000..00a3f8c08324d --- /dev/null +++ b/apps/web/src/components/chat/ReviewCommentDiff.tsx @@ -0,0 +1,53 @@ +import { FileDiff } from "@pierre/diffs/react"; + +import { + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "../../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; +import { + buildReviewCommentRenderablePatch, + type ReviewCommentContext, +} from "../../reviewCommentContext"; +import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; + +/** The diff of a review comment quoted in a user message. Loaded lazily with the diff renderer. */ +export function ReviewCommentDiff({ + comment, + resolvedTheme, +}: { + comment: ReviewCommentContext; + resolvedTheme: "light" | "dark"; +}) { + const renderablePatch = getRenderablePatch( + buildReviewCommentRenderablePatch(comment), + `review-comment:${comment.id}`, + ); + if (renderablePatch?.kind === "files") { + return ( + + {renderablePatch.files.map((fileDiff) => ( + + ))} + + ); + } + if (renderablePatch?.kind === "raw") { + return ( +
+        {renderablePatch.text}
+      
+ ); + } + return null; +} diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 2fc588777a77a..fbb61d9cd891e 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -9,7 +9,7 @@ import type { ThreadPullRequestLink, } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { lazy, Suspense } from "react"; +import { type ComponentProps, lazy, Suspense } from "react"; import type { ComposerImageAttachment, DraftId } from "../../composerDraftStore"; import type { ShortcutMatchContext } from "../../keybindings"; import { type RightPanelSurface, useRightPanelStore } from "../../rightPanelStore"; @@ -18,11 +18,26 @@ import { type PersistentThreadTerminalPanelProps, } from "../PersistentThreadTerminals"; import { pullRequestPanelContext } from "../pullRequest/pullRequestDetail.logic"; -import { PullRequestDetailPanel } from "../pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "../pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "../pullRequest/PullRequestsUnavailableState"; -import { ThreadPullRequestsPanel } from "../pullRequest/ThreadPullRequestsPanel"; +const LazyPullRequestDetailPanel = lazy(() => + import("../pullRequest/PullRequestDetailPanel").then((module) => ({ + default: module.PullRequestDetailPanel, + })), +); +function PullRequestDetailPanel(props: ComponentProps) { + return ( + }> + + + ); +} +const ThreadPullRequestsPanel = lazy(() => + import("../pullRequest/ThreadPullRequestsPanel").then((module) => ({ + default: module.ThreadPullRequestsPanel, + })), +); const PreviewPanel = lazy(() => import("../preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); @@ -161,7 +176,11 @@ export function RightPanelSurfaceContent(props: { ); } case "pull-requests": - return ; + return ( + + + + ); case "device": return ( diff --git a/apps/web/src/components/chat/threadFindHighlights.ts b/apps/web/src/components/chat/threadFindHighlights.ts index e429a15ec5b18..cc94a544affe0 100644 --- a/apps/web/src/components/chat/threadFindHighlights.ts +++ b/apps/web/src/components/chat/threadFindHighlights.ts @@ -1,6 +1,6 @@ import { findThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; import { useEffect, useLayoutEffect, useRef } from "react"; -import { THREAD_FIND_BLOCK_TAGS } from "@t3tools/shared/threadFindText"; +import { THREAD_FIND_BLOCK_TAGS } from "@t3tools/shared/threadFindBlockTags"; const THREAD_FIND_HIGHLIGHT_NAME = "t3-thread-find"; const THREAD_FIND_ACTIVE_HIGHLIGHT_NAME = "t3-thread-find-active"; diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 0f3a263f611bf..bafcf2857e27a 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -39,6 +39,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { FetchHttpClient } from "effect/http"; +import { Atom } from "effect/reactivity"; import { APP_VERSION } from "../branding"; import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; @@ -449,6 +450,12 @@ const loadSecondaryConnectionRegistration = Effect.fn( const PLATFORM_POLL_INTERVAL = "3 seconds"; const SECONDARY_BEARER_REFRESH_SKEW_MS = 5_000; +/** Whether the latest platform poll ended without the primary environment, e.g. its discovery failed. */ +export const primaryEnvironmentMissingAtom = Atom.make(false).pipe( + Atom.keepAlive, + Atom.withLabel("web-primary-environment-missing"), +); + export function secondaryBearerExpiresAtEpochMs( issuedAtEpochMs: number, expiresInSeconds: number, @@ -746,6 +753,10 @@ const layerPlatformConnectionSource = Layer.effect( } yield* Ref.set(cacheRef, next); + const primaryMissing = !next.has(PRIMARY_LOCAL_ENVIRONMENT_ID); + if (appAtomRegistry.get(primaryEnvironmentMissingAtom) !== primaryMissing) { + appAtomRegistry.set(primaryEnvironmentMissingAtom, primaryMissing); + } return registrations as ReadonlyArray; }).pipe(Effect.provide(FetchHttpClient.layer)); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 7b147766ea859..5dfa63d537702 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -341,15 +341,26 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; - syncBrowserChromeTheme(); syncDesktopTheme(theme, followSystem, appearanceMode); if (suppressTransitions) { - // Force a reflow so the no-transitions class takes effect before removal - void document.documentElement.offsetHeight; + settleThemeAfterPaint(); + } else { + syncBrowserChromeTheme(); + } +} + +let themeSettleGeneration = 0; + +// Wait for paint before reading colors or restoring transitions to avoid an extra style recalc. +function settleThemeAfterPaint() { + const generation = ++themeSettleGeneration; + requestAnimationFrame(() => { requestAnimationFrame(() => { + if (generation !== themeSettleGeneration) return; + syncBrowserChromeTheme(); document.documentElement.classList.remove("no-transitions"); }); - } + }); } export async function syncDesktopThemePreference( diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 632e8ec8cf0dc..6df2374b2471a 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./chunkReloadGuard"; +import { reloadOnceForChunkLoadError } from "./chunkReloadGuard"; function createStorageStub(): Storage { const store = new Map(); @@ -25,32 +25,42 @@ describe("reloadOnceForChunkLoadError", () => { const storage = createStorageStub(); const reload = vi.fn(); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reloadOnceForChunkLoadError("a", "chunk", () => storage, reload)).toBe(true); expect(reload).toHaveBeenCalledTimes(1); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(false); + expect(reloadOnceForChunkLoadError("a", "chunk", () => storage, reload)).toBe(false); expect(reload).toHaveBeenCalledTimes(1); }); - it("reloads again after a successful boot cleared the guard", () => { + it("gives each build its own reload", () => { const storage = createStorageStub(); const reload = vi.fn(); - reloadOnceForChunkLoadError(() => storage, reload); - clearChunkReloadGuard(() => storage); + reloadOnceForChunkLoadError("a", "chunk", () => storage, reload); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reloadOnceForChunkLoadError("b", "chunk", () => storage, reload)).toBe(true); expect(reload).toHaveBeenCalledTimes(2); }); + it("bounds failures per build and resets the limit for the next build", () => { + const storage = createStorageStub(); + const reload = vi.fn(); + for (const build of ["a", "b"]) { + for (let index = 0; index < 20; index += 1) { + expect(reloadOnceForChunkLoadError(build, String(index), () => storage, reload)).toBe(true); + } + expect(reloadOnceForChunkLoadError(build, "20", () => storage, reload)).toBe(false); + } + expect(reload).toHaveBeenCalledTimes(40); + }); + it("never reloads when storage is blocked, so a persistent failure cannot loop", () => { const reload = vi.fn(); const blocked = () => { throw new DOMException("blocked", "SecurityError"); }; - expect(reloadOnceForChunkLoadError(blocked, reload)).toBe(false); + expect(reloadOnceForChunkLoadError("a", "chunk", blocked, reload)).toBe(false); expect(reload).not.toHaveBeenCalled(); - expect(() => clearChunkReloadGuard(blocked)).not.toThrow(); }); }); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index 2b8b2838bd51c..787440358a560 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -1,29 +1,39 @@ -// Split chunks are fetched lazily, so a deploy (or desktop server swap) between -// page load and a later fetch can 404 the old hashed assets. One reload picks -// up the fresh index.html. -const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloaded"; +// Split chunks are fetched lazily, so a deploy (or desktop server swap) +// between page load and a later fetch can 404 the old hashed assets. One +// reload picks up the fresh index.html. Remember failures per build so a +// persistent failure cannot loop and later deploys still get a reload. +const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloads"; +const MAX_CHUNK_RELOADS = 20; +/** Returns whether the preload-error listener should swallow the failure for a reload. */ export function reloadOnceForChunkLoadError( + build: string, + failure: string, getStorage: () => Storage = () => window.sessionStorage, reload: () => void = () => window.location.reload(), ): boolean { - let alreadyReloaded: boolean; try { const storage = getStorage(); - alreadyReloaded = storage.getItem(CHUNK_RELOAD_GUARD_KEY) === "1"; - if (!alreadyReloaded) storage.setItem(CHUNK_RELOAD_GUARD_KEY, "1"); + const stored: unknown = JSON.parse(storage.getItem(CHUNK_RELOAD_GUARD_KEY) ?? "null"); + const reloaded = + stored !== null && + typeof stored === "object" && + "build" in stored && + stored.build === build && + "failures" in stored && + Array.isArray(stored.failures) + ? stored.failures + : []; + if (reloaded.includes(failure) || reloaded.length >= MAX_CHUNK_RELOADS) return false; + storage.setItem( + CHUNK_RELOAD_GUARD_KEY, + JSON.stringify({ build, failures: [...reloaded, failure] }), + ); } catch { // Without storage the guard cannot survive a reload, so a persistent // failure would loop forever. Let the error surface instead. return false; } - if (alreadyReloaded) return false; reload(); return true; } - -export function clearChunkReloadGuard(getStorage: () => Storage = () => window.sessionStorage) { - try { - getStorage().removeItem(CHUNK_RELOAD_GUARD_KEY); - } catch {} -} diff --git a/apps/web/src/lib/sharedHighlighter.ts b/apps/web/src/lib/sharedHighlighter.ts new file mode 100644 index 0000000000000..5fcb03d599e34 --- /dev/null +++ b/apps/web/src/lib/sharedHighlighter.ts @@ -0,0 +1,2 @@ +// Split point for shiki: syntaxHighlighting.ts imports this on the first highlight. +export { getSharedHighlighter } from "@pierre/diffs"; diff --git a/apps/web/src/lib/syntaxHighlighting.test.ts b/apps/web/src/lib/syntaxHighlighting.test.ts index 1f7ce756b60ce..4dc60d36246ec 100644 --- a/apps/web/src/lib/syntaxHighlighting.test.ts +++ b/apps/web/src/lib/syntaxHighlighting.test.ts @@ -26,3 +26,19 @@ it("caches the recovered text highlighter for unsupported languages", async () = expect(second).toBe(first); expect(getSharedHighlighter).toHaveBeenCalledTimes(2); }); + +it("keeps a failed load cached so render-time callers get one stable rejection", async () => { + vi.resetModules(); + const { getSyntaxHighlighterPromise: load } = await import("./syntaxHighlighting"); + getSharedHighlighter.mockRejectedValue(new Error("chunk failed")); + const failed = load("typescript"); + await expect(failed).rejects.toThrow("chunk failed"); + const failedText = load("text"); + await expect(failedText).rejects.toThrow("chunk failed"); + const calls = getSharedHighlighter.mock.calls.length; + + getSharedHighlighter.mockResolvedValue({} as DiffsHighlighter); + expect(load("typescript")).toBe(failed); + expect(load("text")).toBe(failedText); + expect(getSharedHighlighter).toHaveBeenCalledTimes(calls); +}); diff --git a/apps/web/src/lib/syntaxHighlighting.ts b/apps/web/src/lib/syntaxHighlighting.ts index 37d29cfda2665..87394b4e517ff 100644 --- a/apps/web/src/lib/syntaxHighlighting.ts +++ b/apps/web/src/lib/syntaxHighlighting.ts @@ -1,9 +1,4 @@ -import { - getSharedHighlighter, - type DiffsHighlighter, - type HighlighterTypes, - type SupportedLanguages, -} from "@pierre/diffs"; +import type { DiffsHighlighter, HighlighterTypes, SupportedLanguages } from "@pierre/diffs"; import { resolveDiffThemeName } from "./diffRendering"; @@ -21,17 +16,26 @@ export function getSyntaxHighlighterPromise(language: string): Promise { - if (language === "text") { - highlighterPromiseCache.delete(language); - throw error; - } - return getSyntaxHighlighterPromise("text"); - }); + // Shiki and its engines load with the first highlight, not with the app. + const promise = import("./sharedHighlighter") + .then(({ getSharedHighlighter }) => + getSharedHighlighter({ + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + langs: [language as SupportedLanguages], + preferredHighlighter: PREFERRED_HIGHLIGHTER, + }), + ) + .catch((error) => { + if (language === "text") { + // "text" itself failed — Shiki cannot initialize at all, surface the error + throw error; + } + // Language not supported by Shiki — fall back to "text" + return getSyntaxHighlighterPromise("text"); + }); + // A failure stays cached too. Callers pass this promise to React `use()`, which needs the + // same promise on every render to reach the error boundary's plain-text fallback, and + // Chromium caches a failed dynamic import, so a retry of the same chunk cannot succeed. highlighterPromiseCache.set(language, promise); return promise; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e2dfab60d81a0..45bbeb0ba210a 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,13 +7,13 @@ import "./index.css"; import { prepareProviderAuthDelivery } from "./providerAuthDelivery"; import { isElectron } from "./env"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; -import { getRouter } from "./router"; +import { getRouter, preloadInitialRouteChunks } from "./router"; import { syncDocumentElectronPlatformClasses, syncDocumentWindowControlsOverlayClass, } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; -import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +import { reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; prepareProviderAuthDelivery(); @@ -21,6 +21,7 @@ prepareProviderAuthDelivery(); const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); +preloadInitialRouteChunks(router); if (isElectron) { syncDocumentElectronPlatformClasses(navigator.platform); @@ -31,11 +32,10 @@ const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string // A failed split-chunk fetch usually means the hashed assets went stale under // a deploy; one guarded reload picks up the fresh index.html. -let chunkLoadFailed = false; let reloadScheduled = false; window.addEventListener("vite:preloadError", (event) => { - chunkLoadFailed = true; - if (reloadOnceForChunkLoadError()) { + if (reloadScheduled) return; + if (reloadOnceForChunkLoadError(import.meta.url, String(event.payload))) { reloadScheduled = true; event.preventDefault(); } @@ -59,9 +59,9 @@ export const startup = Promise.all([ ]) .then(([ManagedAuthShell]) => { // A route chunk failure still resolves router.load(): the error is parked in - // the lazy component and surfaces through the route error boundary. + // the lazy component and surfaces through the route error boundary. Skip the + // paint when a reload is on its way. if (reloadScheduled) return; - if (!chunkLoadFailed) clearChunkReloadGuard(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {ManagedAuthShell && clerkPublishableKey ? ( diff --git a/apps/web/src/markdown-incremental.test.tsx b/apps/web/src/markdown-incremental.test.tsx index 3682deb281fa9..e8b3158fa23b2 100644 --- a/apps/web/src/markdown-incremental.test.tsx +++ b/apps/web/src/markdown-incremental.test.tsx @@ -1,4 +1,4 @@ -import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline"; +import { chatMarkdownRehypePlugins, loadRehypeRaw } from "@t3tools/shared/markdownPipeline"; import type { Root } from "mdast"; import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown from "react-markdown"; @@ -11,6 +11,8 @@ import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts"; import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation"; +const CHAT_MARKDOWN_REHYPE_PLUGINS = chatMarkdownRehypePlugins(await loadRehypeRaw()); + function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) { let tree: Root | undefined; const observeParsing: Plugin<[], Root> = function () { diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index e8fd2b71d73f1..3392a8d224eff 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -13,6 +13,20 @@ export function getRouter(history: RouterHistory) { export type AppRouter = ReturnType; +// Overlap route chunks with auth; defer one task so boot requests start first. +export function preloadInitialRouteChunks(router: AppRouter) { + const { pathname } = router.latestLocation; + const routes = [...router.getMatchedRoutes(pathname).matchedRoutes]; + const draftRoute = router.looseRoutesById["/_chat/draft/$draftId"]; + if (pathname === "/" && draftRoute) routes.push(draftRoute); + setTimeout(() => { + for (const route of routes) { + // Failures surface again when the router loads the same chunk. + void Promise.resolve(router.loadRouteChunk(route)).catch(() => undefined); + } + }, 0); +} + declare module "@tanstack/react-router" { interface Register { router: AppRouter; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ece2903be3d3e..9677de68a7ade 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -22,7 +22,6 @@ import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; import { KeybindingsConfigWarning } from "../components/KeybindingsConfigWarning"; import { FirstRunGate } from "../components/onboarding/FirstRunGate"; -import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; @@ -53,6 +52,8 @@ import { toastManager, } from "../components/ui/toast"; import { isElectron } from "../env"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { cn } from "../lib/utils"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; @@ -77,6 +78,7 @@ import { primaryServerWelcomeAtom, } from "../state/server"; import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; +import { startEnvironmentShells } from "../state/shell"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, @@ -97,6 +99,7 @@ export const Route = createRootRoute({ } if (isLocalEnvironmentDisabled() || isHostedStaticApp(new URL(window.location.href))) { + startEnvironmentShells(); return { authGateState: { status: "hosted-static", @@ -104,7 +107,10 @@ export const Route = createRootRoute({ }; } - const authGateState = await resolveInitialServerAuthGateState(); + const authGate = resolveInitialServerAuthGateState(); + const authGateState = await authGate; + // Connect and read cached shells while the route chunks still load. + if (authGateState.status === "authenticated") startEnvironmentShells(); if ( authGateState.status === "authenticated" && getDesktopSnapShotBridge() && @@ -234,7 +240,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {isElectron ? : null} - + {hasCloudPublicConfig() ? : null} diff --git a/apps/web/src/routes/pair.tsx b/apps/web/src/routes/pair.tsx index 769ad48abda9f..b6829a35b6a4c 100644 --- a/apps/web/src/routes/pair.tsx +++ b/apps/web/src/routes/pair.tsx @@ -5,6 +5,8 @@ import { PairingPendingSurface, PairingRouteSurface, } from "../components/auth/PairingRouteSurface"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; export const Route = createFileRoute("/pair")({ beforeLoad: async ({ context }) => { @@ -42,8 +44,13 @@ function PairRouteView() { { - // Recreate the primary connection so its WebSocket and cached scopes - // use the newly issued cookie after re-pairing. + // A first pairing has no connection yet, so the app continues in + // place. Re-pairing reloads to recreate the primary connection, so its + // WebSocket and cached scopes use the newly issued cookie. + if (!appAtomRegistry.getNodes().has(connectionAtomRuntime)) { + void router.navigate({ to: "/", replace: true }); + return; + } router.history.replace("/"); router.history.flush(); window.location.reload(); diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index 38269987e7513..03de91bf365c0 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -19,8 +19,11 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; +import { primaryEnvironmentMissingAtom } from "../connection/platform"; import { connectionAtomRuntime } from "../connection/runtime"; import { isHostedStaticApp } from "../hostedPairing"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; +import { appAtomRegistry } from "../rpc/atomRegistry"; export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); @@ -54,6 +57,17 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { if (Option.isNone(catalog)) { return false; } + // Wait for primary discovery before treating a persisted catalog as complete. + if ( + !isHostedStaticApp() && + !isLocalEnvironmentDisabled() && + !get(primaryEnvironmentMissingAtom) && + !Array.from(catalog.value.entries.values()).some( + (entry) => entry.target._tag === "PrimaryConnectionTarget", + ) + ) { + return false; + } for (const environmentId of enabledEnvironmentIds(catalog.value)) { if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) { continue; @@ -72,6 +86,15 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { return true; }).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped")); +let environmentShellsStarted = false; + +// Keep the shell mounted for the session, starting cache reads and connections before render. +export function startEnvironmentShells(): void { + if (environmentShellsStarted) return; + environmentShellsStarted = true; + appAtomRegistry.mount(allEnvironmentShellsBootstrappedAtom); +} + /** * The persisted catalog can emit before platform discovery registers the primary environment. * Neither that gap nor an empty catalog proves a project is absent. diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cd50954cd7613..94e4dfb90a827 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - Vite build hooks run outside an Effect runtime. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; import * as NodeZlib from "node:zlib"; import react from "@vitejs/plugin-react"; @@ -17,6 +21,7 @@ import { import { loadRepoEnv } from "../../scripts/lib/public-config"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; +import { lucideDynamicIconsPlugin, startupChunksPlugin } from "./vite/startupChunks"; import { tailwindPlugins } from "./vite/tailwind"; const repoEnv = loadRepoEnv(); @@ -169,6 +174,52 @@ const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "") .filter((entry) => entry.length > 0); const allowedHosts = [".ts.net", ...configuredAllowedHosts]; +// Only hashed assets get sidecars, so they cannot describe stale content. +function precompressPlugin(): Plugin { + const brotli = NodeUtil.promisify(NodeZlib.brotliCompress); + const gzip = NodeUtil.promisify(NodeZlib.gzip); + return { + name: "t3code:precompress", + apply: "build", + async writeBundle(options, bundle) { + if (!options.dir) return; + const outDir = options.dir; + const files = Object.keys(bundle).filter( + (file) => + file.startsWith("assets/") && /\.(?:js|mjs|css|json|svg|wasm|txt|xml)$/.test(file), + ); + await Promise.all( + files.map(async (file) => { + const path = NodePath.join(outDir, file); + const source = await NodeFSP.readFile(path); + if (source.byteLength < 1024) return; + const copies = [ + [ + ".br", + brotli(source, { + params: { + [NodeZlib.constants.BROTLI_PARAM_QUALITY]: 11, + [NodeZlib.constants.BROTLI_PARAM_MODE]: file.endsWith(".wasm") + ? NodeZlib.constants.BROTLI_MODE_GENERIC + : NodeZlib.constants.BROTLI_MODE_TEXT, + [NodeZlib.constants.BROTLI_PARAM_SIZE_HINT]: source.byteLength, + }, + }), + ], + [".gz", gzip(source, { level: 9 })], + ] as const; + await Promise.all( + copies.map(async ([suffix, compressed]) => { + const copy = await compressed; + if (copy.byteLength < source.byteLength) await NodeFSP.writeFile(path + suffix, copy); + }), + ); + }), + ); + }, + }; +} + export default defineConfig(() => { return { assetsInclude: ["**/*.wasm"], @@ -186,6 +237,10 @@ export default defineConfig(() => { tanstackRouter({ autoCodeSplitting: true }), react({ compiler: true }), tailwindPlugins(bundledDev), + lucideDynamicIconsPlugin(), + startupChunksPlugin(), + // Vercel compresses the hosted app itself; the T3 server serves these copies. + ...(process.env.VERCEL ? [] : [precompressPlugin()]), ], optimizeDeps: { include: [ @@ -282,7 +337,9 @@ export default defineConfig(() => { build: { outDir: "dist", emptyOutDir: true, - manifest: true, + // Not under .vite/: release artifact uploads skip hidden directories, and + // the server reads this file to mark hashed assets immutable. + manifest: "vite-manifest.json", sourcemap: buildSourcemap, }, test: { diff --git a/apps/web/vite/startupChunks.ts b/apps/web/vite/startupChunks.ts new file mode 100644 index 0000000000000..f133292f23302 --- /dev/null +++ b/apps/web/vite/startupChunks.ts @@ -0,0 +1,191 @@ +// @effect-diagnostics nodeBuiltinImport:off - Vite resolves module paths before an Effect runtime exists. +import * as NodePath from "node:path"; + +import { normalizePath, type HtmlTagDescriptor, type Plugin, type Rolldown } from "vite-plus"; + +// Keep the initial route graphs in sync with src/routes. +const STARTUP_MODULES = { + main: "src/main.tsx", + chat: "src/routes/_chat.tsx?tsr-split=component", + "chat-index": "src/routes/_chat.index.tsx?tsr-split=component", + "chat-draft": "src/routes/_chat.draft.$draftId.tsx?tsr-split=component", + "chat-thread": "src/routes/_chat.$environmentId.$threadId.tsx?tsr-split=component", +}; +// Group static startup graphs without pulling chat-only code into other routes. +// Bootstrap stays separate so it can report main's chunk-load failures. + +export function startupChunksPlugin(): Plugin { + let root = process.cwd(); + let base = "/"; + let bootstrapModules: ReadonlySet = new Set(); + let startupModuleOwners: ReadonlyMap = new Map(); + let startupFiles: ReadonlyArray = []; + let startupFileSet: ReadonlySet = new Set(); + let startupCss: ReadonlyArray = []; + + return { + name: "t3code:startup-chunks", + apply: "build", + config: (config) => { + root = normalizePath(NodePath.resolve(config.root ?? process.cwd())); + return { + build: { + modulePreload: { + resolveDependencies: (_file, deps, { hostType }) => + hostType === "js" ? deps.filter((dep) => !startupFileSet.has(dep)) : deps, + }, + rolldownOptions: { + input: { + index: normalizePath(NodePath.join(root, "index.html")), + ...Object.fromEntries( + Object.entries(STARTUP_MODULES).map(([name, id]) => [ + name, + normalizePath(NodePath.join(root, id)), + ]), + ), + }, + output: { + codeSplitting: { + // Keep each graph whole: size splitting creates chunk cycles inside Effect's + // eagerly initialized runtime and can run consumers before their dependencies. + groups: [ + ...Object.keys(STARTUP_MODULES).map((name) => ({ + name: `startup-${name}`, + tags: ["$initial" as const], + test: (id: string) => + !id.startsWith("\0") && startupModuleOwners.get(normalizePath(id)) === name, + // Dynamic settings/theme entries must not fragment the startup graphs. + entriesAware: false, + // Otherwise excluded bootstrap helpers are recaptured through main's imports. + includeDependenciesRecursively: false, + })), + ], + }, + }, + }, + }, + }; + }, + configResolved(config) { + base = config.base; + }, + buildEnd(error) { + if (error) return; + const moduleIds = new Map([...this.getModuleIds()].map((id) => [normalizePath(id), id])); + const staticClosure = (...entryIds: ReadonlyArray) => { + const closure = new Set(); + const pending = entryIds.flatMap((entryId) => moduleIds.get(entryId) ?? []); + for (let id = pending.pop(); id !== undefined; id = pending.pop()) { + const normalizedId = normalizePath(id); + if (closure.has(normalizedId)) continue; + closure.add(normalizedId); + pending.push(...(this.getModuleInfo(id)?.importedIds ?? [])); + } + return closure; + }; + bootstrapModules = staticClosure(normalizePath(NodePath.join(root, "index.html"))); + const owners = new Map(); + for (const [name, entry] of Object.entries(STARTUP_MODULES)) { + for (const id of staticClosure(normalizePath(NodePath.join(root, entry)))) { + if (!bootstrapModules.has(id) && !owners.has(id)) owners.set(id, name); + } + } + startupModuleOwners = owners; + }, + generateBundle: { + order: "pre", + handler(_options, bundle) { + const chunks = Object.values(bundle).filter( + (output): output is Rolldown.OutputChunk => output.type === "chunk", + ); + const importClosure = (roots: ReadonlyArray) => { + const files = new Set(); + const pending = roots.map((chunk) => chunk.fileName); + for (let file = pending.pop(); file !== undefined; file = pending.pop()) { + const chunk = bundle[file]; + if (chunk?.type !== "chunk" || files.has(file)) continue; + files.add(file); + pending.push(...chunk.imports); + } + return files; + }; + const bootstrapFiles = importClosure( + chunks.filter( + (chunk) => chunk.facadeModuleId === normalizePath(NodePath.join(root, "index.html")), + ), + ); + const mainId = normalizePath(NodePath.join(root, STARTUP_MODULES.main)); + if ( + [...bootstrapFiles].some((file) => { + const chunk = bundle[file]; + return ( + chunk?.type === "chunk" && + chunk.moduleIds.some( + (id) => !id.startsWith("\0") && !bootstrapModules.has(normalizePath(id)), + ) + ); + }) + ) { + this.error( + "The boot entry must catch main's chunk load failures before importing app code.", + ); + } + // Main may become a facade; routes retain their own preload lists. + const files = [ + ...importClosure( + chunks.filter( + (chunk) => chunk.facadeModuleId === mainId || chunk.moduleIds.includes(mainId), + ), + ), + ].filter((file) => !bootstrapFiles.has(file)); + const chunkAt = (file: string) => { + const chunk = bundle[file]; + return chunk?.type === "chunk" ? chunk : undefined; + }; + startupFiles = files.toSorted( + (left, right) => (chunkAt(right)?.code.length ?? 0) - (chunkAt(left)?.code.length ?? 0), + ); + startupFileSet = new Set(startupFiles); + startupCss = [ + ...new Set( + files.flatMap((file) => [...(chunkAt(file)?.viteMetadata?.importedCss ?? [])]), + ), + ]; + }, + }, + transformIndexHtml: { + order: "post", + handler: (): HtmlTagDescriptor[] => [ + // A stylesheet link would hold the splash until the CSS arrives; the + // runtime still inserts the stylesheet before main runs. + ...startupCss.map((file): HtmlTagDescriptor => ({ + tag: "link", + attrs: { rel: "preload", as: "style", crossorigin: true, href: `${base}${file}` }, + injectTo: "head", + })), + ...startupFiles.map((file): HtmlTagDescriptor => ({ + tag: "link", + attrs: { rel: "modulepreload", crossorigin: true, href: `${base}${file}` }, + injectTo: "head", + })), + ], + }, + }; +} + +// Separate dynamic icon ids so static icons don't each become a startup chunk. +export function lucideDynamicIconsPlugin(): Plugin { + const suffix = "?lucide-dynamic"; + return { + name: "t3code:lucide-dynamic-icons", + apply: "build", + enforce: "pre", + async resolveId(source, importer) { + if (!source.startsWith("./icons/") || !importer?.endsWith("/dynamicIconImports.js")) { + return null; + } + const resolved = await this.resolve(source, importer, { skipSelf: true }); + return resolved ? `${resolved.id}${suffix}` : null; + }, + }; +} diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index eb7a6217c7723..81c5e7ba412ad 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -106,6 +106,22 @@ const emptyProjection = { } as OrchestrationV2ThreadProjection; describe("applyOrchestrationV2ProjectionEvent", () => { + it("applies compact visits without changing activity or transcript state", () => { + const visitedAt = DateTime.add(now, { hours: 1 }); + const event = { + id: "event-visit-recorded", + type: "thread.visit-recorded", + threadId, + occurredAt: visitedAt, + payload: { lastVisitedAt: visitedAt }, + } as OrchestrationV2DomainEvent; + const next = applyOrchestrationV2ProjectionEvent(emptyProjection, event); + expect(next?.thread.lastVisitedAt).toEqual(visitedAt); + expect(next?.thread.updatedAt).toEqual(now); + expect(next?.updatedAt).toEqual(now); + expect(next?.turnItems).toBe(emptyProjection.turnItems); + }); + it("applies a compact PR sync without replacing other links or thread metadata", () => { const link = (number: number): ThreadPullRequestLink => ({ host: "github.com", @@ -132,6 +148,27 @@ describe("applyOrchestrationV2ProjectionEvent", () => { }); expect(next?.thread).toEqual({ ...projection.thread, pullRequests: [links[0], updated] }); expect(next?.thread.pullRequests?.[0]).toBe(links[0]); + const snapshot = { + state: "open", + title: "Host title", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: DateTime.formatIso(now), + syncedAt: DateTime.formatIso(now), + }; + const synced = applyOrchestrationV2ProjectionEvent(projection, { + id: "host-sync", + type: "thread.pull-request-link-synced", + threadId, + occurredAt: DateTime.add(now, { hours: 1 }), + payload: { ...links[1], snapshot, stack: null }, + } as OrchestrationV2DomainEvent); + expect(synced?.thread.pullRequests?.[1]?.snapshot).toEqual(snapshot); + expect(synced?.thread.pullRequests?.[0]).toBe(links[0]); + expect(synced?.thread.updatedAt).toEqual(now); + expect(synced?.updatedAt).toEqual(now); + expect(synced?.turnItems).toBe(projection.turnItems); }); it("keeps live token usage when the terminal provider turn omits it", () => { diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index c93044e93f272..f5d4e4c769fb1 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -9,6 +9,7 @@ import { createOrchestrationV2TurnItemVisibility, isOrchestrationV2TurnItemVisible, } from "@t3tools/shared/orchestrationV2Timeline"; +import { applyThreadPullRequestLinkSync } from "@t3tools/shared/threadPullRequests"; export type ApplyOrchestrationV2ProjectionEventOptions = { readonly partialTimeline?: boolean; @@ -225,10 +226,21 @@ export function applyOrchestrationV2ProjectionEvent( case "thread.model-selection-updated": case "thread.provider-switched": return { ...base, thread: event.payload }; + // A host refresh is not activity, matching the server projection. + case "thread.pull-request-link-synced": + return { + ...projection, + thread: applyThreadPullRequestLinkSync(projection.thread, event.payload), + }; // Visited tracking is read state, not activity: skip the updatedAt bump. case "thread.visited": case "thread.marked-unread": return { ...projection, thread: event.payload }; + case "thread.visit-recorded": + return { + ...projection, + thread: { ...projection.thread, lastVisitedAt: event.payload.lastVisitedAt }, + }; case "run.created": case "run.updated": { const next = { ...base, runs: upsertEntity(base.runs, event.payload) }; diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index fc8b8e5008133..dfeab0bc9f704 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -853,6 +853,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return { threadId, + acceptThreadFieldEvents: true as const, ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), ...(acceptBoundedSnapshot diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 94a9eca331d13..65be14b9724c1 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1580,6 +1580,18 @@ export const OrchestrationV2RawProviderEvent = Schema.Struct({ }); export type OrchestrationV2RawProviderEvent = typeof OrchestrationV2RawProviderEvent.Type; +/** A host refresh carries one link snapshot. */ +const OrchestrationV2ThreadPullRequestLinkSynced = Schema.Struct({ + ...ThreadPullRequestKey.fields, + snapshot: ThreadPullRequestSnapshot, + stack: Schema.NullOr(ThreadPullRequestStack), +}); + +/** The thread's read watermark after a visit, the only field a visit changes. */ +const OrchestrationV2ThreadVisitRecorded = Schema.Struct({ + lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc), +}); + const OrchestrationV2EventBase = Schema.Struct({ id: EventId, threadId: ThreadId, @@ -1623,6 +1635,16 @@ export const OrchestrationV2DomainEvent = Schema.Union([ ]), payload: OrchestrationV2AppThread, }), + Schema.Struct({ + ...OrchestrationV2EventBase.fields, + type: Schema.Literal("thread.pull-request-link-synced"), + payload: OrchestrationV2ThreadPullRequestLinkSynced, + }), + Schema.Struct({ + ...OrchestrationV2EventBase.fields, + type: Schema.Literal("thread.visit-recorded"), + payload: OrchestrationV2ThreadVisitRecorded, + }), Schema.Struct({ ...OrchestrationV2EventBase.fields, type: Schema.Literal("thread.pull-request-link-updated"), @@ -2430,6 +2452,19 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ ]), payload: OrchestrationV2AppThreadJson, }), + Schema.Struct({ + ...OrchestrationV2JsonEventBaseFields, + type: Schema.Literal("thread.pull-request-link-synced"), + payload: OrchestrationV2ThreadPullRequestLinkSynced, + }), + Schema.Struct({ + ...OrchestrationV2JsonEventBaseFields, + type: Schema.Literal("thread.visit-recorded"), + payload: OrchestrationV2ThreadVisitRecorded.mapFields((fields) => ({ + ...fields, + lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + })), + }), Schema.Struct({ ...OrchestrationV2JsonEventBaseFields, type: Schema.Literal("thread.pull-request-link-updated"), @@ -3190,6 +3225,8 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), acceptBoundedSnapshot: Schema.optionalKey(Schema.Boolean), acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), + /** Accepts compact read-watermark and pull-request-link events. */ + acceptThreadFieldEvents: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeThreadInput = typeof OrchestrationV2SubscribeThreadInput.Type; diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index ad78126cc00ac..4fe907009097e 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -44,6 +44,12 @@ export class ProviderHost extends Context.Service< readonly subscribe: Effect.Effect, never, Scope.Scope>; }; readonly shouldRunBackgroundWork: (scope: BackgroundScope) => Effect.Effect; + /** + * Resolves when a provider's first status probe may run. A starting server + * holds it until its first client has loaded; hosts without a startup + * phase omit it. + */ + readonly awaitStartupProbe?: Effect.Effect; /** * Absolute path of a stored chat attachment, or `null` when its id does not * resolve inside the attachments directory. diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 0cbb238cf1f80..9d2fa03d50601 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -140,6 +140,45 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("parks the initial probe and reads settings again when startup releases it", () => + Effect.gen(function* () { + const host = yield* ProviderHost.ProviderHost; + const startup = yield* Deferred.make(); + const settings = yield* Ref.make({ enabled: true }); + const settingsChanges = yield* PubSub.unbounded(); + const checkCalls = yield* Ref.make(0); + const enrichedWith = yield* Deferred.make(); + yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Ref.get(settings), + streamSettings: Stream.fromPubSub(settingsChanges), + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.as(refreshedSnapshot), + ), + enrichSnapshot: ({ settings }) => + Deferred.succeed(enrichedWith, settings).pipe(Effect.asVoid), + refreshInterval: "1 hour", + }).pipe( + Effect.provideService(ProviderHost.ProviderHost, { + ...host, + awaitStartupProbe: Deferred.await(startup), + }), + ); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(checkCalls), 0); + yield* Ref.set(settings, { enabled: false }); + // A settings change while parked does not probe early. + yield* PubSub.publish(settingsChanges, { enabled: false }); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(checkCalls), 0); + yield* Deferred.succeed(startup, undefined); + assert.deepStrictEqual(yield* Deferred.await(enrichedWith), { enabled: false }); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }).pipe(Effect.provide(layerAlwaysRunTest)), + ); + it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index e90d5eefa04b7..0187b540a4f95 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -252,8 +252,11 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ); } + const awaitStartupProbe = host.awaitStartupProbe ?? Effect.void; + + // A settings change while startup holds the first probe waits for it too. yield* Stream.runForEach(input.streamSettings, (nextSettings) => - Effect.asVoid(applySnapshot(nextSettings)), + awaitStartupProbe.pipe(Effect.andThen(applySnapshot(nextSettings)), Effect.asVoid), ).pipe(Effect.forkScoped); yield* Effect.forever( @@ -285,7 +288,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ), ).pipe(Effect.forkScoped); - yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( + // Until it lands, clients see the cached or pending snapshot. + yield* awaitStartupProbe.pipe( + Effect.andThen(refreshSnapshot()), Effect.ignoreCause({ log: true }), Effect.forkScoped, ); diff --git a/packages/shared/package.json b/packages/shared/package.json index 22f240aa163a0..88a7093e06098 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -459,6 +459,10 @@ "types": "./src/threadFindText.ts", "import": "./src/threadFindText.ts" }, + "./threadFindBlockTags": { + "types": "./src/threadFindBlockTags.ts", + "import": "./src/threadFindBlockTags.ts" + }, "./threadSearch": { "types": "./src/threadSearch.ts", "import": "./src/threadSearch.ts" diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index b546fcc57c650..b9243946604fb 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -1,19 +1,27 @@ -import { expect, it } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; -import { CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS } from "./markdownPipeline.ts"; +import rehypeRaw from "rehype-raw"; +import { + CHAT_MARKDOWN_REMARK_PLUGINS, + chatMarkdownRehypePlugins, + markdownMayContainRawHtml, +} from "./markdownPipeline.ts"; -it("preserves in-app thread links through the shared Markdown sanitizer", () => { +function chatMarkdownTree(markdown: string, rawHtml: boolean) { const processor = unified() .use(remarkParse) .use(CHAT_MARKDOWN_REMARK_PLUGINS) .use(remarkRehype, { allowDangerousHtml: true }) - .use(CHAT_MARKDOWN_REHYPE_PLUGINS); - const tree = processor.runSync( - processor.parse( - '[Open thread](t3-thread://v1/environment/thread)\n\nUnsafe', - ), + .use(chatMarkdownRehypePlugins(rawHtml ? rehypeRaw : null)); + return processor.runSync(processor.parse(markdown)); +} + +it("preserves in-app thread links through the shared Markdown sanitizer", () => { + const tree = chatMarkdownTree( + '[Open thread](t3-thread://v1/environment/thread)\n\nUnsafe', + true, ); expect(tree.children[0]).toMatchObject({ tagName: "p", @@ -28,12 +36,41 @@ it("preserves in-app thread links through the shared Markdown sanitizer", () => expect(JSON.stringify(tree)).not.toContain("javascript:"); }); +it("flags every way markdown can start raw HTML", () => { + for (const markdown of ["
", "a", "", "", ""]) { + expect(markdownMayContainRawHtml(markdown)).toBe(true); + } +}); + +it("renders tag-free markdown without loading raw HTML", () => { + const markdown = "# Title\n\n1 < 2 and **bold** with `code`"; + expect(markdownMayContainRawHtml(markdown)).toBe(false); + expect(chatMarkdownTree(markdown, false).children).toEqual( + chatMarkdownTree(markdown, true).children, + ); +}); + +it("keeps a failed load so every render waits on the same settled promise", async () => { + vi.resetModules(); + vi.doMock("rehype-raw", () => { + throw new Error("chunk failed"); + }); + try { + const { loadRehypeRaw } = await import("./markdownPipeline.ts"); + const failed = loadRehypeRaw(); + await expect(failed).rejects.toThrow(); + expect(loadRehypeRaw()).toBe(failed); + } finally { + vi.doUnmock("rehype-raw"); + } +}); + it("keeps backslashes that CommonMark would read as escapes in prose Windows paths", () => { const processor = unified() .use(remarkParse) .use(CHAT_MARKDOWN_REMARK_PLUGINS) .use(remarkRehype, { allowDangerousHtml: true }) - .use(CHAT_MARKDOWN_REHYPE_PLUGINS); + .use(chatMarkdownRehypePlugins(rehypeRaw)); type Node = { type: string; value?: string; tagName?: string; children?: Node[] }; const text = (node: Node): string => node.type === "text" diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index d8717c6273fb3..8140faf7b8f42 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -1,7 +1,6 @@ import type { Processor } from "unified"; import { isWindowsAbsolutePath } from "./path.ts"; -import type { PluggableList } from "unified"; -import rehypeRaw from "rehype-raw"; +import type { Pluggable, PluggableList } from "unified"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; @@ -177,12 +176,39 @@ export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [ remarkNormalizeLinksAndTagInlineCode, ]; -export const CHAT_MARKDOWN_REHYPE_PLUGINS: PluggableList = [ - rehypePreserveBareAnchorPlaceholders, - rehypeRaw, - rehypePreserveImageSourceMeta, - [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], -]; +/** + * The chat rehype pipeline, parsing raw HTML only when given rehype-raw. The web + * client loads it once a message has a tag, so parse5 stays out of its startup; + * a static import anywhere in this module would pull it back into that chunk. + */ +export function chatMarkdownRehypePlugins(rawHtml: Pluggable | null): PluggableList { + return [ + rehypePreserveBareAnchorPlaceholders, + ...(rawHtml ? [rawHtml] : []), + rehypePreserveImageSourceMeta, + [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], + ]; +} + +let rehypeRaw: Pluggable | undefined; +let rehypeRawPromise: Promise | undefined; + +/** + * Loads rehype-raw for clients that keep parse5 out of their startup bundle. A failed + * load stays cached: browsers cache a failed dynamic import, so a retry cannot succeed. + */ +export function loadRehypeRaw(): Promise { + rehypeRawPromise ??= import("rehype-raw").then((module) => (rehypeRaw = module.default)); + return rehypeRawPromise; +} + +/** rehype-raw once {@link loadRehypeRaw} has resolved, for synchronous renders. */ +export const loadedRehypeRaw = (): Pluggable | undefined => rehypeRaw; + +/** Raw HTML always starts with `<` and a letter, `/`, `!` or `?`; anything else parses the same without rehype-raw. */ +export function markdownMayContainRawHtml(markdown: string): boolean { + return /<[A-Za-z/!?]/.test(markdown); +} type MarkdownAstNode = { type?: string; diff --git a/packages/shared/src/threadFindBlockTags.ts b/packages/shared/src/threadFindBlockTags.ts new file mode 100644 index 0000000000000..685c56619f8b8 --- /dev/null +++ b/packages/shared/src/threadFindBlockTags.ts @@ -0,0 +1,39 @@ +// Inline wrappers (including Shiki token spans) must not split a search phrase. +export const THREAD_FIND_BLOCK_TAGS = new Set([ + "address", + "article", + "aside", + "blockquote", + "br", + "dd", + "details", + "div", + "dl", + "dt", + "figcaption", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "summary", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "ul", +]); diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index a2e5b4dbb982d..d248cf8c1b5d1 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -28,52 +28,16 @@ import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanTex import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; +import rehypeRaw from "rehype-raw"; import { shouldPreserveAssistantLineBreaks, - CHAT_MARKDOWN_REHYPE_PLUGINS, + chatMarkdownRehypePlugins, CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, } from "./markdownPipeline.ts"; +import { THREAD_FIND_BLOCK_TAGS } from "./threadFindBlockTags.ts"; -// Inline wrappers (including Shiki token spans) must not split a search phrase. -export const THREAD_FIND_BLOCK_TAGS = new Set([ - "address", - "article", - "aside", - "blockquote", - "br", - "dd", - "details", - "div", - "dl", - "dt", - "figcaption", - "figure", - "footer", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "header", - "hr", - "li", - "main", - "nav", - "ol", - "p", - "pre", - "section", - "summary", - "table", - "tbody", - "td", - "th", - "thead", - "tr", - "ul", -]); +const CHAT_MARKDOWN_REHYPE_PLUGINS = chatMarkdownRehypePlugins(rehypeRaw); const assistantProcessor = unified() .use(remarkParse) diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 4e593fa358fc9..cba22a5c051a4 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -366,6 +366,42 @@ export function threadPullRequestsOf(thread: { ]; } +/** + * A thread after one link takes a fresh host snapshot, as `thread.pull-request-link-synced` + * records it. A link the thread no longer has leaves it unchanged. The legacy single link is + * kept only while a visible link still matches it. + */ +export function applyThreadPullRequestLinkSync< + Thread extends { + readonly pullRequests?: ReadonlyArray | undefined; + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + }, +>( + thread: Thread, + sync: ThreadPullRequestKey & Pick, +): Thread { + const links = threadPullRequestsOf(thread); + const existing = links.find((link) => threadPullRequestKeysEqual(link, sync)); + if (existing === undefined) return thread; + const pullRequests = links.map((link) => + link === existing ? { ...link, snapshot: sync.snapshot, stack: sync.stack } : link, + ); + const linked = thread.linkedPullRequest; + return { + ...thread, + pullRequests, + linkedPullRequest: + linked && + pullRequests.some( + (link) => + link.source !== "stack-dismissed" && + threadPullRequestKeysEqual(link, legacyThreadPullRequestKey(linked)), + ) + ? linked + : null, + }; +} + export function applyThreadPullRequestLinkUpdate( thread: OrchestrationV2AppThread, updated: ThreadPullRequestLink,