From 216a61b7a2e4942f8b4258c32538edfd58ed239d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 03:58:50 +0000 Subject: [PATCH 1/6] perf(startup): reduce desktop imports and cache dev compilation --- apps/desktop/vite.config.ts | 12 +++++++++++- scripts/dev-runner.test.ts | 27 +++++++++++++++++++++++++++ scripts/dev-runner.ts | 5 +++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 294a02030a83..c23712930bdf 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,6 +1,7 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; +import { isExternalCliDependency } from "../../scripts/lib/cli-external-packages.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; const repoEnv = loadRepoEnv(); @@ -48,7 +49,16 @@ export default defineConfig({ entry: ["src/main.ts"], clean: true, deps: { - alwaysBundle: (id) => id.startsWith("@t3tools/"), + // Avoid loading the Effect module graph from disk before Electron can start. + alwaysBundle: (id) => + id.startsWith("@t3tools/") || + id === "effect" || + id.startsWith("effect/") || + id === "@effect/platform-node" || + id.startsWith("@effect/platform-node/") || + id === "@effect/platform-node-shared" || + id.startsWith("@effect/platform-node-shared/"), + neverBundle: isExternalCliDependency, }, ...(shouldLaunchElectronAfterPack ? { onSuccess: "node scripts/dev-electron.mjs" } : {}), }, diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 9b4f44475d95..537e14084b21 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -217,6 +217,10 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); assert.equal(env.T3CODE_HOME, path.resolve("/tmp/custom-t3")); + assert.equal( + env.NODE_COMPILE_CACHE, + path.resolve("/tmp/custom-t3", "cache", "node-compile"), + ); assert.equal(env.T3CODE_PORT, "4222"); assert.equal(env.VITE_HTTP_URL, "http://localhost:4222"); assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); @@ -228,6 +232,29 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); + it.effect("preserves Node compile-cache overrides and its disable flag", () => + Effect.gen(function* () { + for (const cache of ["/tmp/custom-compile-cache", ""]) { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: { NODE_COMPILE_CACHE: cache, NODE_DISABLE_COMPILE_CACHE: "1" }, + serverOffset: 0, + webOffset: 0, + t3Home: "/tmp/custom-t3", + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.NODE_COMPILE_CACHE, cache); + assert.equal(env.NODE_DISABLE_COMPILE_CACHE, "1"); + } + }), + ); + it.effect("strips inherited service-launcher context", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index d426cc7829b1..6b08699031cc 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -324,10 +324,15 @@ export function createDevRunnerEnv({ // by the caller; an unset t3Home here genuinely means "use the default". const configuredBaseDir = t3Home?.trim() || undefined; const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); + const path = yield* Path.Path; const isDesktopMode = mode === "dev:desktop"; const output: NodeJS.ProcessEnv = { ...baseEnv, + // Reuse compiled modules across dev launches. Node handles invalidation + // and still honors an inherited NODE_DISABLE_COMPILE_CACHE. + NODE_COMPILE_CACHE: + baseEnv.NODE_COMPILE_CACHE ?? path.join(resolvedBaseDir, "cache", "node-compile"), PORT: String(webPort), VITE_DEV_SERVER_URL: devUrl?.toString() ?? From 69aaa3a66fc8d49c963e29ac79b5bcd3d9b95552 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 05:27:56 +0000 Subject: [PATCH 2/6] perf(startup): overlap desktop loading and defer provider work --- apps/desktop/src/app/DesktopApp.ts | 16 +++ .../DesktopBackendConfiguration.test.ts | 5 + .../backend/DesktopBackendConfiguration.ts | 3 + .../DesktopLocalEnvironmentAuth.test.ts | 117 +++++++++++++----- .../backend/DesktopLocalEnvironmentAuth.ts | 8 ++ .../src/electron/ElectronProtocol.test.ts | 65 +++++++++- apps/desktop/src/electron/ElectronProtocol.ts | 50 +++++++- apps/desktop/src/main.ts | 2 +- apps/desktop/src/window/DesktopWindow.test.ts | 45 +++++++ apps/desktop/src/window/DesktopWindow.ts | 8 +- .../src/provider/Layers/ClaudeAdapter.ts | 50 ++++---- .../src/provider/Layers/ClaudeProvider.ts | 15 +-- .../makeManagedServerProvider.test.ts | 36 ++++++ .../src/provider/makeManagedServerProvider.ts | 5 +- package.json | 10 +- scripts/lib/node-compile-cache.mjs | 6 + 16 files changed, 357 insertions(+), 84 deletions(-) create mode 100644 scripts/lib/node-compile-cache.mjs diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d5a8ac3b7836..7385afc654e5 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,5 +1,6 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -178,6 +179,15 @@ const bootstrap = Effect.gen(function* () { const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const fileSystem = yield* FileSystem.FileSystem; + const bundledStaticRoot = environment.path.join( + environment.serverRoot, + "apps/server/dist/client", + ); + const loadRendererWhileStarting = + !environment.isDevelopment && + !(settings.wslOnly === true && settings.wslBackendEnabled === true) && + (yield* fileSystem.exists(environment.path.join(bundledStaticRoot, "index.html"))); const rendererTarget = environment.isDevelopment ? Option.getOrThrow(environment.devServerUrl) : backendConfig.httpBaseUrl; @@ -186,6 +196,7 @@ const bootstrap = Effect.gen(function* () { targetOrigin: rendererTarget, backendOrigin: backendConfig.httpBaseUrl, clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + ...(loadRendererWhileStarting ? { staticRoot: bundledStaticRoot } : {}), }); yield* logBootstrapInfo("bootstrap resolved backend endpoint", { baseUrl: backendConfig.httpBaseUrl.href, @@ -213,6 +224,11 @@ const bootstrap = Effect.gen(function* () { } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); + if (loadRendererWhileStarting) { + yield* desktopWindow.ensureMain.pipe( + Effect.catch((error) => logStartupError("early renderer creation failed", { error })), + ); + } 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/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index accfdf70b3a3..7d34d6b16135 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -240,6 +240,11 @@ describe("DesktopBackendConfiguration", () => { assert.equal(first.cwd, environment.backendCwd); assert.equal(first.captureOutput, true); assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1"); + assert.equal( + first.env.NODE_COMPILE_CACHE, + process.env.NODE_COMPILE_CACHE ?? + environment.path.join(environment.baseDir, "cache", "node-compile"), + ); assert.isUndefined(first.env.T3CODE_PORT); assert.isUndefined(first.env.T3CODE_MODE); assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 4c43070b5f97..db017716be22 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -507,6 +507,9 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", + NODE_COMPILE_CACHE: + process.env.NODE_COMPILE_CACHE ?? + environment.path.join(environment.baseDir, "cache", "node-compile"), }, // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). extendEnv: true, diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index e7a58baef140..ba0855a3d616 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,5 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -30,52 +32,99 @@ const config = { }; describe("DesktopLocalEnvironmentAuth", () => { - it.effect("exchanges the desktop bootstrap credential only once", () => + it.effect("does not exchange a credential when backend readiness times out", () => Effect.gen(function* () { const requestCount = yield* Ref.make(0); - const httpClientLayer = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Ref.update(requestCount, (count) => count + 1).pipe( - Effect.as( - HttpClientResponse.fromWeb( - request, - new Response( - JSON.stringify({ - access_token: "desktop-bearer-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "Bearer", - expires_in: 3600, - scope: "orchestration:read", - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ), - ), - ), - ), - ); const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { list: Effect.succeed([ { id: PRIMARY_LOCAL_ENVIRONMENT_ID, - label: Effect.succeed("Windows"), currentConfig: Effect.succeed(Option.some(config)), + waitForReady: () => Effect.succeed(false), }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); - const testLayer = DesktopLocalEnvironmentAuth.layer.pipe( - Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)), + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => + Ref.update(requestCount, (count) => count + 1).pipe( + Effect.andThen(Effect.die("unexpected HTTP request before readiness")), + ), + ), ); - - const [first, second] = yield* Effect.gen(function* () { + const error = yield* Effect.gen(function* () { const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; - return yield* Effect.all([auth.getBearerToken, auth.getBearerToken]); - }).pipe(Effect.provide(testLayer)); - - assert.strictEqual(first, "desktop-bearer-token"); - assert.strictEqual(second, "desktop-bearer-token"); - assert.strictEqual(yield* Ref.get(requestCount), 1); + return yield* Effect.flip(auth.getBearerToken); + }).pipe( + Effect.provide( + DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)), + ), + ), + ); + assert.equal(error._tag, "DesktopLocalEnvironmentAuthSessionBootstrapError"); + assert.equal(yield* Ref.get(requestCount), 0); }), ); + + it.effect( + "waits for backend readiness and exchanges the desktop bootstrap credential only once", + () => + Effect.gen(function* () { + const requestCount = yield* Ref.make(0); + const waiting = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Ref.update(requestCount, (count) => count + 1).pipe( + Effect.as( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + access_token: "desktop-bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ), + ), + ), + ); + const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([ + { + id: PRIMARY_LOCAL_ENVIRONMENT_ID, + label: Effect.succeed("Windows"), + currentConfig: Effect.succeed(Option.some(config)), + waitForReady: () => + Deferred.succeed(waiting, undefined).pipe(Effect.andThen(Deferred.await(ready))), + }, + ]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); + const testLayer = DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)), + ); + + const [first, second] = yield* Effect.gen(function* () { + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; + const authentication = yield* Effect.all([auth.getBearerToken, auth.getBearerToken], { + concurrency: 2, + }).pipe(Effect.forkChild); + yield* Deferred.await(waiting); + assert.strictEqual(yield* Ref.get(requestCount), 0); + yield* Deferred.succeed(ready, true); + return yield* Fiber.join(authentication); + }).pipe(Effect.provide(testLayer)); + + assert.strictEqual(first, "desktop-bearer-token"); + assert.strictEqual(second, "desktop-bearer-token"); + assert.strictEqual(yield* Ref.get(requestCount), 1); + }), + ); }); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 201492f0e4c1..82a6a0176bc7 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -2,6 +2,7 @@ import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorizat import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Duration from "effect/Duration"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -67,6 +68,13 @@ export const make = Effect.gen(function* () { if (!credential) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } + // Renderer assets can load while the local server starts. Every primary + // HTTP request already awaits this token, so gate the exchange here. + if (primary === undefined || !(yield* primary.waitForReady(Duration.seconds(60)))) { + return yield* new DesktopLocalEnvironmentAuthSessionBootstrapError({ + cause: new Error("Local backend did not become ready for authentication."), + }); + } const session = yield* bootstrapRemoteBearerSession({ httpBaseUrl: config.httpBaseUrl.href, credential, diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index a5c03e0b9336..004aa6f473c7 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,7 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Layer from "effect/Layer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { beforeEach, vi } from "vite-plus/test"; +import * as NodeURL from "node:url"; const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -15,6 +20,7 @@ vi.mock("electron", () => ({ })); import * as ElectronProtocol from "./ElectronProtocol.ts"; +const protocolLayer = ElectronProtocol.layer.pipe(Layer.provideMerge(NodeServices.layer)); describe("ElectronProtocol", () => { beforeEach(() => { @@ -23,6 +29,55 @@ describe("ElectronProtocol", () => { unhandleMock.mockReset(); }); + it.effect("serves bundled renderer files before the backend and keeps API requests on HTTP", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockImplementation(async () => new Response("ok")); + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const staticRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-protocol-" }); + yield* fileSystem.writeFileString(path.join(staticRoot, "index.html"), "app"); + yield* fileSystem.makeDirectory(path.join(staticRoot, "assets")); + yield* fileSystem.writeFileString(path.join(staticRoot, "assets/main.js"), "app"); + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3773/"), + clerkFrontendApiHostname: undefined, + staticRoot, + }); + const response = yield* Effect.promise(() => handler!(new Request("t3code://app/"))); + assert.equal( + netFetchMock.mock.calls[0]?.[0], + NodeURL.pathToFileURL(path.join(staticRoot, "index.html")).href, + ); + assert.include( + response.headers.get("content-security-policy") ?? "", + "default-src 'self'", + ); + yield* Effect.promise(() => handler!(new Request("t3code://app/assets/main.js"))); + assert.equal( + netFetchMock.mock.calls[1]?.[0], + NodeURL.pathToFileURL(path.join(staticRoot, "assets/main.js")).href, + ); + yield* Effect.promise(() => handler!(new Request("t3code://app/api/health"))); + assert.equal(netFetchMock.mock.calls[2]?.[0], "http://127.0.0.1:3773/api/health"); + const escaped = yield* Effect.promise(() => + handler!(new Request("t3code://app/%2e%2e%2fsecret")), + ); + assert.equal(escaped.status, 403); + assert.equal(netFetchMock.mock.calls.length, 3); + }), + ); + }).pipe(Effect.scoped, Effect.provide(protocolLayer)), + ); + it.effect("proxies the stable renderer origin to the current app server", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; @@ -85,7 +140,7 @@ describe("ElectronProtocol", () => { assert.isNull(forwardedHeaders.get("referer")); assert.isNull(forwardedHeaders.get("sec-fetch-site")); assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("rejects custom protocol requests for another host", () => @@ -110,7 +165,7 @@ describe("ElectronProtocol", () => { assert.equal(response.status, 404); assert.equal(netFetchMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("retries transient renderer target failures", () => @@ -138,7 +193,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ready"); assert.equal(netFetchMock.mock.calls.length, 2); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("preserves protocol registration failures", () => @@ -162,7 +217,7 @@ describe("ElectronProtocol", () => { assert.equal(error.scheme, "t3code-dev"); assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("preserves protocol unregistration failures", () => @@ -192,7 +247,7 @@ describe("ElectronProtocol", () => { assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); } - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it("keeps executable sources host-restricted while allowing runtime network resources", () => { diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index fabd598d7ffa..7ef1d002cacd 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -2,6 +2,9 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as NodeTimersPromises from "node:timers/promises"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as NodeURL from "node:url"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -53,6 +56,7 @@ export interface DesktopProtocolRegistrationInput { readonly targetOrigin: URL; readonly backendOrigin: URL; readonly clerkFrontendApiHostname: string | undefined; + readonly staticRoot?: string; } export class ElectronProtocol extends Context.Service< @@ -185,6 +189,41 @@ async function proxyRequest( return withContentSecurityPolicy(response, contentSecurityPolicy); } +async function serveRendererRequest( + request: Request, + input: DesktopProtocolRegistrationInput, + contentSecurityPolicy: string, + path: Path.Path, + isFile: (filePath: string) => Promise, +): Promise { + const url = new URL(request.url); + if (url.host !== DESKTOP_HOST) return new Response(null, { status: 404 }); + + if (input.staticRoot && (request.method === "GET" || request.method === "HEAD")) { + let pathname: string; + try { + pathname = decodeURIComponent(url.pathname); + } catch { + return new Response(null, { status: 400 }); + } + const root = path.resolve(input.staticRoot); + const filePath = path.resolve(root, `.${pathname === "/" ? "/index.html" : pathname}`); + const relative = path.relative(root, filePath); + if (relative.startsWith("..") || path.isAbsolute(relative) || pathname.includes("\\")) { + return new Response(null, { status: 403 }); + } + if (await isFile(filePath)) { + const response = await Electron.net.fetch(NodeURL.pathToFileURL(filePath).href, { + method: request.method, + headers: request.headers, + }); + return withContentSecurityPolicy(response, contentSecurityPolicy); + } + } + + return proxyRequest(request, input.targetOrigin, contentSecurityPolicy); +} + const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const; async function fetchWithTransientRetry(url: string, init: RequestInit): Promise { @@ -206,6 +245,15 @@ async function fetchWithTransientRetry(url: string, init: RequestInit): Promise< } export const make = Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const isFile = (filePath: string) => + Effect.runPromise( + fileSystem.stat(filePath).pipe( + Effect.map((stat) => stat.type === "File"), + Effect.orElseSucceed(() => false), + ), + ); const registered = yield* Ref.make(false); const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( @@ -218,7 +266,7 @@ export const make = Effect.gen(function* () { Effect.try({ try: () => { Electron.protocol.handle(input.scheme, (request) => - proxyRequest(request, input.targetOrigin, contentSecurityPolicy), + serveRendererRequest(request, input, contentSecurityPolicy, path, isFile), ); }, catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3337228aa962..0e6c3e331a90 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -129,7 +129,7 @@ const electronLayer = Layer.mergeAll( ElectronUpdater.layer, ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), -); +).pipe(Layer.provide(NodeServices.layer)); const desktopFoundationLayer = Layer.mergeAll( DesktopState.layer, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index bdd03865c7bf..32dcea8f0464 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -209,6 +209,7 @@ function makeTestLayer(input: { ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; readonly previewZoomReapplies?: number[]; + readonly beforeCreate?: Effect.Effect; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -249,6 +250,7 @@ function makeTestLayer(input: { Effect.sync(() => { input.createdWindowOptions?.push(options); }).pipe( + Effect.andThen(input.beforeCreate ?? Effect.void), Effect.andThen(Ref.update(input.createCount, (count) => count + 1)), Effect.as(input.window), ), @@ -400,6 +402,49 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); describe("DesktopWindow", () => { + it.effect("creates only one main window when startup and backend readiness overlap", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const started = yield* Deferred.make(); + const proceed = yield* Deferred.make(); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + beforeCreate: Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(proceed)), + ), + }); + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const startup = yield* desktopWindow.ensureMain.pipe(Effect.forkChild); + yield* Deferred.await(started); + const backendReady = yield* desktopWindow + .handleBackendReady(new URL("http://127.0.0.1:3773")) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(proceed, undefined); + assert.strictEqual(yield* Fiber.join(startup), fakeWindow.window); + yield* Fiber.join(backendReady); + assert.equal(yield* Ref.get(createCount), 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("retries ensuring the main window after creation fails", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const scenario = yield* makeSplashScenario([null, fakeWindow.window]); + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + assert.isTrue(Option.isNone(yield* desktopWindow.ensureMain.pipe(Effect.option))); + assert.strictEqual(yield* desktopWindow.ensureMain, fakeWindow.window); + assert.equal(yield* Ref.get(scenario.createCalls), 2); + }).pipe(Effect.provide(scenario.layer)); + }), + ); + it("leaves fullscreen before concealing a pending quit", () => { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index d87c74428a99..64251d286e48 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"; @@ -298,6 +299,7 @@ export const make = Effect.gen(function* () { // The transient "Connecting to WSL" splash window, tracked separately so it // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); + const mainWindowCreation = yield* Semaphore.make(1); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); @@ -788,7 +790,7 @@ export const make = Effect.gen(function* () { return existingWindow.value; } return yield* createMain; - }).pipe(Effect.withSpan("desktop.window.ensureMain")); + }).pipe(mainWindowCreation.withPermits(1), Effect.withSpan("desktop.window.ensureMain")); const revealOrCreateMain = Effect.gen(function* () { const window = yield* ensureMain; @@ -799,9 +801,7 @@ export const make = Effect.gen(function* () { const createMainIfBackendReady = Effect.gen(function* () { const backendReady = yield* Ref.get(backendReadyRef); if (!backendReady) 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* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index a005f583066f..6b0ff0df6e4c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -6,19 +6,18 @@ * * @module ClaudeAdapterLive */ -import { - type CanUseTool, - query, - type Options as ClaudeQueryOptions, - type PermissionMode, - type PermissionResult, - type PermissionUpdate, - type SDKMessage, - type SDKRateLimitInfo, - type SDKResultMessage, - type SettingSource, - type SDKUserMessage, - type ModelUsage, +import type { + CanUseTool, + Options as ClaudeQueryOptions, + PermissionMode, + PermissionResult, + PermissionUpdate, + SDKMessage, + SDKRateLimitInfo, + SDKResultMessage, + SettingSource, + SDKUserMessage, + ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; @@ -1961,17 +1960,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const createQuery = - options?.createQuery ?? - ((input: { - readonly prompt: AsyncIterable; - readonly options: ClaudeQueryOptions; - }) => - query({ - prompt: input.prompt, - options: input.options, - }) as ClaudeQueryRuntime); - const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); @@ -4724,12 +4712,24 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.path_to_executable": claudeBinaryPath, }); + const createQuery = + options?.createQuery ?? + (yield* Effect.tryPromise({ + try: () => import("@anthropic-ai/claude-agent-sdk").then((sdk) => sdk.query), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: "Failed to load Claude runtime SDK.", + cause, + }), + })); const queryRuntime = yield* Effect.try({ try: () => createQuery({ prompt, options: queryOptions, - }), + }) as ClaudeQueryRuntime, catch: (cause) => new ProviderAdapterProcessError({ provider: PROVIDER, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e3d2c6ab565d..3381c54249d2 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -13,13 +13,12 @@ import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { - query as claudeQuery, - type Options as ClaudeQueryOptions, - type SlashCommand as ClaudeSlashCommand, - type SDKControlGetUsageResponse, - type SDKUserMessage, - type SettingSource, +import type { + Options as ClaudeQueryOptions, + SlashCommand as ClaudeSlashCommand, + SDKControlGetUsageResponse, + SDKUserMessage, + SettingSource, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -340,6 +339,8 @@ const probeClaudeCapabilities = ( claudeEnvironment, ); return yield* Effect.tryPromise(async () => { + const { query: claudeQuery } = await import("@anthropic-ai/claude-agent-sdk"); + abort.signal.throwIfAborted(); const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. // This prevents any prompt from reaching the Anthropic API. diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index aa0828e048c9..0aa182e58678 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -18,6 +18,7 @@ import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import { ServerActivation } from "../serverActivation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; @@ -150,6 +151,41 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("parks background provider checks until server activation", () => + Effect.scoped( + Effect.gen(function* () { + const activation = yield* Deferred.make(); + const checkCalls = yield* Ref.make(0); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 second", + }).pipe(Effect.provideService(ServerActivation, Deferred.await(activation))); + + yield* TestClock.adjust("10 seconds"); + assert.strictEqual(yield* Ref.get(checkCalls), 0); + assert.deepStrictEqual(yield* provider.getSnapshot, initialSnapshot); + + const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(activation, undefined); + + assert.deepStrictEqual(Array.from(yield* Fiber.join(updatesFiber)), [refreshedSnapshot]); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + assert.deepStrictEqual(yield* provider.getSnapshot, refreshedSnapshot); + }), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); + it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index ec3d26e6c87e..ca5e71fecca8 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -16,6 +16,7 @@ import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import { forkParked } from "../serverActivation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; @@ -275,11 +276,11 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ), Effect.ignoreCause({ log: true }), ), - ).pipe(Effect.forkScoped); + ).pipe(forkParked); yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( Effect.ignoreCause({ log: true }), - Effect.forkScoped, + forkParked, ); return { diff --git a/package.json b/package.json index 46913a435a8a..1a8ccee417bc 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "type": "module", "scripts": { "prepare": "node scripts/clean-tsgo-backups.mjs && effect-tsgo patch && vp config --no-agent", - "dev": "node scripts/dev-runner.ts dev", - "dev:share": "node scripts/dev-runner.ts dev --share", - "dev:server": "node scripts/dev-runner.ts dev:server", - "dev:web": "node scripts/dev-runner.ts dev:web", + "dev": "node --import ./scripts/lib/node-compile-cache.mjs scripts/dev-runner.ts dev", + "dev:share": "node --import ./scripts/lib/node-compile-cache.mjs scripts/dev-runner.ts dev --share", + "dev:server": "node --import ./scripts/lib/node-compile-cache.mjs scripts/dev-runner.ts dev:server", + "dev:web": "node --import ./scripts/lib/node-compile-cache.mjs scripts/dev-runner.ts dev:web", "dev:marketing": "vp run --filter @t3tools/marketing dev", - "dev:desktop": "node scripts/dev-runner.ts dev:desktop", + "dev:desktop": "node --import ./scripts/lib/node-compile-cache.mjs scripts/dev-runner.ts dev:desktop", "migrate-dev-db": "node apps/server/scripts/migrate-dev-db.ts", "start": "vp run --filter t3 start", "start:desktop": "vp run --filter @t3tools/desktop start", diff --git a/scripts/lib/node-compile-cache.mjs b/scripts/lib/node-compile-cache.mjs new file mode 100644 index 000000000000..4f7cdf1a4e3e --- /dev/null +++ b/scripts/lib/node-compile-cache.mjs @@ -0,0 +1,6 @@ +import * as NodeModule from "node:module"; + +// Preload before the dev runner's imports so its CLI graph is cached too. +// Node honors NODE_COMPILE_CACHE and NODE_DISABLE_COMPILE_CACHE and falls +// back to its per-user temporary cache when no directory was configured. +NodeModule.enableCompileCache(); From dfc38d44a95e78972a51a872204ae6bd9a4bf189 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 05:46:23 +0000 Subject: [PATCH 3/6] fix(desktop): preserve startup close and slow backend recovery --- .../DesktopLocalEnvironmentAuth.test.ts | 13 ++++++-- .../backend/DesktopLocalEnvironmentAuth.ts | 4 +-- apps/desktop/src/window/DesktopWindow.test.ts | 30 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 7 ++++- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index ba0855a3d616..6c73f3ab1fed 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,7 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; +import type * as Duration from "effect/Duration"; import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -32,7 +34,7 @@ const config = { }; describe("DesktopLocalEnvironmentAuth", () => { - it.effect("does not exchange a credential when backend readiness times out", () => + it.effect("does not exchange a credential when the backend stops before readiness", () => Effect.gen(function* () { const requestCount = yield* Ref.make(0); const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { @@ -102,8 +104,12 @@ describe("DesktopLocalEnvironmentAuth", () => { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), currentConfig: Effect.succeed(Option.some(config)), - waitForReady: () => - Deferred.succeed(waiting, undefined).pipe(Effect.andThen(Deferred.await(ready))), + waitForReady: (timeout: Duration.Duration) => + Deferred.succeed(waiting, undefined).pipe( + Effect.andThen(Deferred.await(ready)), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrElse(() => false)), + ), }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); @@ -117,6 +123,7 @@ describe("DesktopLocalEnvironmentAuth", () => { concurrency: 2, }).pipe(Effect.forkChild); yield* Deferred.await(waiting); + yield* TestClock.adjust("2 minutes"); assert.strictEqual(yield* Ref.get(requestCount), 0); yield* Deferred.succeed(ready, true); return yield* Fiber.join(authentication); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 82a6a0176bc7..bb23fe5846e3 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -70,9 +70,9 @@ export const make = Effect.gen(function* () { } // Renderer assets can load while the local server starts. Every primary // HTTP request already awaits this token, so gate the exchange here. - if (primary === undefined || !(yield* primary.waitForReady(Duration.seconds(60)))) { + if (primary === undefined || !(yield* primary.waitForReady(Duration.infinity))) { return yield* new DesktopLocalEnvironmentAuthSessionBootstrapError({ - cause: new Error("Local backend did not become ready for authentication."), + cause: new Error("Local backend stopped before authentication was ready."), }); } const session = yield* bootstrapRemoteBearerSession({ diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 32dcea8f0464..0d26d6d560b3 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -402,6 +402,36 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); describe("DesktopWindow", () => { + it.effect("keeps the early window closed until explicit activation", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.ensureMain; + fakeWindow.isDestroyed.mockReturnValue(true); + fakeWindow.windowListeners.get("closed")?.(); + yield* Effect.yieldNow; + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + fakeWindow.isDestroyed.mockReturnValue(false); + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + yield* desktopWindow.handleBackendNotReady; + fakeWindow.windowListeners.get("closed")?.(); + yield* Effect.yieldNow; + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 3); + }).pipe( + Effect.provide(makeTestLayer({ window: fakeWindow.window, createCount, mainWindow })), + ); + }), + ); + it.effect("creates only one main window when startup and backend readiness overlap", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 64251d286e48..58b766a25c22 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -300,6 +300,7 @@ export const make = Effect.gen(function* () { // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); const mainWindowCreation = yield* Semaphore.make(1); + let mainWindowClosed = false; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); @@ -769,6 +770,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { + mainWindowClosed = true; clearDevelopmentLoadRetry(); clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); @@ -778,6 +780,7 @@ export const make = Effect.gen(function* () { }); const createMain = Effect.gen(function* () { + mainWindowClosed = false; const window = yield* createWindow(); yield* electronWindow.setMain(window); yield* logWindowInfo("main window created"); @@ -855,6 +858,7 @@ export const make = Effect.gen(function* () { ensureMain, revealOrCreateMain, activate: Effect.gen(function* () { + mainWindowClosed = false; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { yield* electronWindow.reveal(existingWindow.value); @@ -880,7 +884,8 @@ 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 }); - yield* createMainIfBackendReady; + // Readiness must not undo an intentional close of the early window. + if (!mainWindowClosed) yield* createMainIfBackendReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), From ac3756f992be15c4c5fc90e3dea279c4f92827ac Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 05:48:36 +0000 Subject: [PATCH 4/6] fix(desktop): preserve effect context and typed readiness errors --- .../backend/DesktopLocalEnvironmentAuth.test.ts | 2 +- .../src/backend/DesktopLocalEnvironmentAuth.ts | 14 +++++++++++--- apps/desktop/src/electron/ElectronProtocol.ts | 3 ++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 6c73f3ab1fed..7ba062f0989c 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -64,7 +64,7 @@ describe("DesktopLocalEnvironmentAuth", () => { ), ), ); - assert.equal(error._tag, "DesktopLocalEnvironmentAuthSessionBootstrapError"); + assert.equal(error._tag, "DesktopLocalEnvironmentAuthBackendStoppedError"); assert.equal(yield* Ref.get(requestCount), 0); }), ); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index bb23fe5846e3..4506093bd6e6 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -21,6 +21,15 @@ export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema } } +export class DesktopLocalEnvironmentAuthBackendStoppedError extends Schema.TaggedErrorClass()( + "DesktopLocalEnvironmentAuthBackendStoppedError", + {}, +) { + override get message(): string { + return "Local backend stopped before authentication was ready."; + } +} + export class DesktopLocalEnvironmentAuthSessionBootstrapError extends Schema.TaggedErrorClass()( "DesktopLocalEnvironmentAuthSessionBootstrapError", { cause: Schema.Defect() }, @@ -32,6 +41,7 @@ export class DesktopLocalEnvironmentAuthSessionBootstrapError extends Schema.Tag export const DesktopLocalEnvironmentAuthError = Schema.Union([ DesktopLocalEnvironmentAuthBackendNotConfiguredError, + DesktopLocalEnvironmentAuthBackendStoppedError, DesktopLocalEnvironmentAuthSessionBootstrapError, ]); export type DesktopLocalEnvironmentAuthError = typeof DesktopLocalEnvironmentAuthError.Type; @@ -71,9 +81,7 @@ export const make = Effect.gen(function* () { // Renderer assets can load while the local server starts. Every primary // HTTP request already awaits this token, so gate the exchange here. if (primary === undefined || !(yield* primary.waitForReady(Duration.infinity))) { - return yield* new DesktopLocalEnvironmentAuthSessionBootstrapError({ - cause: new Error("Local backend stopped before authentication was ready."), - }); + return yield* new DesktopLocalEnvironmentAuthBackendStoppedError(); } const session = yield* bootstrapRemoteBearerSession({ httpBaseUrl: config.httpBaseUrl.href, diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 7ef1d002cacd..f3c1e563127c 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -247,8 +247,9 @@ async function fetchWithTransientRetry(url: string, init: RequestInit): Promise< export const make = Effect.gen(function* () { const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; + const runPromise = Effect.runPromiseWith(yield* Effect.context()); const isFile = (filePath: string) => - Effect.runPromise( + runPromise( fileSystem.stat(filePath).pipe( Effect.map((stat) => stat.type === "File"), Effect.orElseSucceed(() => false), From 6896cd31cc6b2faaac6a763dd33fde3237ab5802 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 06:30:38 +0000 Subject: [PATCH 5/6] perf(startup): defer provider schemas and cache desktop compilation --- apps/desktop/scripts/dev-electron.mjs | 3 +- apps/desktop/src/app/DesktopApp.ts | 2 +- apps/desktop/src/bootstrap.ts | 8 + .../src/preview/BrowserImport/ChromiumKeys.ts | 5 +- .../src/shell/DesktopShellEnvironment.test.ts | 33 +++ .../src/shell/DesktopShellEnvironment.ts | 16 +- apps/desktop/vite.config.ts | 6 +- .../src/provider/Layers/CodexAdapter.ts | 190 +++++++++--------- .../src/provider/Layers/CodexProvider.ts | 20 +- .../Layers/CodexSessionRuntime.test.ts | 20 +- .../provider/Layers/CodexSessionRuntime.ts | 92 ++++++--- .../src/provider/acp/AcpSessionRuntime.ts | 18 +- apps/web/src/main.tsx | 6 + packages/contracts/package.json | 1 + 14 files changed, 260 insertions(+), 160 deletions(-) create mode 100644 apps/desktop/src/bootstrap.ts diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index b5bcc4d06e36..c1ceb046f498 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -23,11 +23,12 @@ if (!Number.isInteger(port) || port <= 0) { const requiredFiles = [ "dist-electron/main.cjs", + "dist-electron/runtime.cjs", "dist-electron/preload.cjs", "../server/dist/bin.mjs", ]; const watchedDirectories = [ - { directory: "dist-electron", files: new Set(["main.cjs", "preload.cjs"]) }, + { directory: "dist-electron", files: new Set(["main.cjs", "runtime.cjs", "preload.cjs"]) }, { directory: "../server/dist", files: new Set(["bin.mjs"]) }, ]; const forcedShutdownTimeoutMs = 1_500; diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 7385afc654e5..6680cef50b58 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -310,7 +310,7 @@ const startup = Effect.gen(function* () { yield* applicationMenu.configure; yield* updates.configure; yield* DesktopRemoteUpdates.listen; - yield* linuxUrlHandler.register; + yield* Effect.forkScoped(linuxUrlHandler.register); yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/bootstrap.ts b/apps/desktop/src/bootstrap.ts new file mode 100644 index 000000000000..3e3e3fa4eabb --- /dev/null +++ b/apps/desktop/src/bootstrap.ts @@ -0,0 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - compile caching must precede the application runtime. +import * as NodeModule from "node:module"; + +// Node honors cache overrides and disable flags; an unavailable cache is nonfatal. +NodeModule.enableCompileCache(); + +// Stay synchronous so Electron's pre-ready configuration cannot miss ready. +NodeModule.createRequire(__filename)("./runtime.cjs"); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index d32462662a36..e18c1c0cf9e2 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -17,7 +17,6 @@ * * @module ChromiumKeys */ -import * as Keyring from "@napi-rs/keyring"; import * as NodeCrypto from "node:crypto"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; @@ -105,6 +104,10 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function service: string, account: string, ) { + const Keyring = yield* Effect.tryPromise({ + try: () => import("@napi-rs/keyring"), + catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), + }); const secret = yield* Effect.try({ try: () => new Keyring.Entry(service, account).getPassword(), catch: (cause) => { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 5a76402b1d34..f1a65a79a1ce 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -100,6 +101,38 @@ function runShellEnvironment(input: { } describe("DesktopShellEnvironment", () => { + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "captures exported POSIX values literally and excludes unexported shell variables", + () => + Effect.gen(function* () { + let capture = ""; + yield* runShellEnvironment({ + env: { SHELL: "/bin/sh", PATH: "/usr/bin" }, + platform: "linux", + handler: (command) => { + if (command._tag === "StandardCommand") capture = command.args[1] ?? ""; + return envOutput({ PATH: "/usr/bin" }); + }, + }); + assert.notEqual(capture, ""); + const values = { + PATH: "/a path:/usr/bin", + SSH_AUTH_SOCK: "quote'\" $HOME `literal`\\socket\nsecond line\n", + }; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = yield* spawner.string( + ChildProcess.make("/bin/sh", ["-c", `LANG=unexported; ${capture}`], { + env: values, + extendEnv: false, + }), + ); + for (const [name, value] of Object.entries(values)) { + assert.include(output, envOutput({ [name]: value })); + } + assert.include(output, envOutput({ LANG: "" })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index b4610eee5c84..f1d4a042d522 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -235,16 +235,14 @@ const logShellEnvironmentCommandError = ( }), ); -const capturePosixEnvironmentCommand = (names: ReadonlyArray) => - names - .map((name) => { - return [ - `printf '%s\\n' '${startMarker(name)}'`, - `printenv ${name} || true`, - `printf '%s\\n' '${endMarker(name)}'`, - ].join("; "); - }) +const capturePosixEnvironmentCommand = (names: ReadonlyArray) => { + // One POSIX child reads the exported environment, including when the login + // shell is fish. Spawning printenv for every variable adds up at launch. + const capture = names + .map((name) => `printf "%s\\n" "${startMarker(name)}" "\${${name}-}" "${endMarker(name)}"`) .join("; "); + return `/bin/sh -c '${capture}'`; +}; const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => [ diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index c23712930bdf..3feb305cc58f 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -46,7 +46,7 @@ export default defineConfig({ sourcemap: true, outExtensions: () => ({ js: ".cjs" }), define: publicConfigDefine, - entry: ["src/main.ts"], + entry: { main: "src/bootstrap.ts", runtime: "src/main.ts" }, clean: true, deps: { // Avoid loading the Effect module graph from disk before Electron can start. @@ -57,7 +57,9 @@ export default defineConfig({ id === "@effect/platform-node" || id.startsWith("@effect/platform-node/") || id === "@effect/platform-node-shared" || - id.startsWith("@effect/platform-node-shared/"), + id.startsWith("@effect/platform-node-shared/") || + id === "electron-updater" || + id.startsWith("electron-updater/"), neverBundle: isExternalCliDependency, }, ...(shouldLaunchElectronAfterPack ? { onSuccess: "node scripts/dev-electron.mjs" } : {}), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index d1981b33d47d..71dfff83db56 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -43,7 +43,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexErrors from "effect-codex-app-server/errors"; -import * as EffectCodexSchema from "effect-codex-app-server/schema"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; @@ -986,13 +986,14 @@ function runtimeEventBase( } function mapItemLifecycle( + codexSchema: typeof EffectCodexSchema, event: ProviderEvent, canonicalThreadId: ThreadId, lifecycle: "item.started" | "item.updated" | "item.completed", ): ProviderRuntimeEvent | undefined { const payload = - readPayload(EffectCodexSchema.V2ItemStartedNotification, event.payload) ?? - readPayload(EffectCodexSchema.V2ItemCompletedNotification, event.payload); + readPayload(codexSchema.V2ItemStartedNotification, event.payload) ?? + readPayload(codexSchema.V2ItemCompletedNotification, event.payload); const item = payload?.item; if (!item) { return undefined; @@ -1293,6 +1294,7 @@ function mapCollabAgentEvent( } function mapToRuntimeEvents( + codexSchema: typeof EffectCodexSchema, event: ProviderEvent, canonicalThreadId: ThreadId, ): ReadonlyArray { @@ -1319,8 +1321,8 @@ function mapToRuntimeEvents( if (event.kind === "request") { if (event.method === "item/tool/requestUserInput") { const payload = - readPayload(EffectCodexSchema.ServerRequest__ToolRequestUserInputParams, event.payload) ?? - readPayload(EffectCodexSchema.ToolRequestUserInputParams, event.payload); + readPayload(codexSchema.ServerRequest__ToolRequestUserInputParams, event.payload) ?? + readPayload(codexSchema.ToolRequestUserInputParams, event.payload); const questions = payload ? toUserInputQuestions(payload.questions) : undefined; if (!questions) { return []; @@ -1338,21 +1340,21 @@ function mapToRuntimeEvents( const elicitation = event.method === "mcpServer/elicitation/request" - ? readPayload(EffectCodexSchema.McpServerElicitationRequestParams, event.payload) + ? readPayload(codexSchema.McpServerElicitationRequestParams, event.payload) : undefined; const elicitationApproval = elicitation ? describeMcpElicitation(elicitation) : undefined; const detail = (() => { switch (event.method) { case "item/commandExecution/requestApproval": { const payload = readPayload( - EffectCodexSchema.ServerRequest__CommandExecutionRequestApprovalParams, + codexSchema.ServerRequest__CommandExecutionRequestApprovalParams, event.payload, ); return payload?.command ?? payload?.reason ?? undefined; } case "item/fileChange/requestApproval": { const payload = readPayload( - EffectCodexSchema.ServerRequest__FileChangeRequestApprovalParams, + codexSchema.ServerRequest__FileChangeRequestApprovalParams, event.payload, ); // These params carry no path of their own, only the root the agent @@ -1363,7 +1365,7 @@ function mapToRuntimeEvents( return elicitation?.message; case "applyPatchApproval": { const payload = readPayload( - EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams, + codexSchema.ServerRequest__ApplyPatchApprovalParams, event.payload, ); return ( @@ -1374,14 +1376,14 @@ function mapToRuntimeEvents( } case "execCommandApproval": { const payload = readPayload( - EffectCodexSchema.ServerRequest__ExecCommandApprovalParams, + codexSchema.ServerRequest__ExecCommandApprovalParams, event.payload, ); return payload?.reason ?? payload?.command.join(" "); } case "item/tool/call": { const payload = readPayload( - EffectCodexSchema.ServerRequest__DynamicToolCallParams, + codexSchema.ServerRequest__DynamicToolCallParams, event.payload, ); return payload?.tool ?? undefined; @@ -1482,7 +1484,7 @@ function mapToRuntimeEvents( } if (event.method === "thread/started") { - const payload = readPayload(EffectCodexSchema.V2ThreadStartedNotification, event.payload); + const payload = readPayload(codexSchema.V2ThreadStartedNotification, event.payload); if (!payload) { return []; } @@ -1506,7 +1508,7 @@ function mapToRuntimeEvents( ) { const payload = event.method === "thread/status/changed" - ? readPayload(EffectCodexSchema.V2ThreadStatusChangedNotification, event.payload) + ? readPayload(codexSchema.V2ThreadStatusChangedNotification, event.payload) : undefined; return [ { @@ -1530,7 +1532,7 @@ function mapToRuntimeEvents( } if (event.method === "thread/name/updated") { - const payload = readPayload(EffectCodexSchema.V2ThreadNameUpdatedNotification, event.payload); + const payload = readPayload(codexSchema.V2ThreadNameUpdatedNotification, event.payload); return [ { type: "thread.metadata.updated", @@ -1553,10 +1555,7 @@ function mapToRuntimeEvents( } if (event.method === "thread/tokenUsage/updated") { - const payload = readPayload( - EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification, - event.payload, - ); + const payload = readPayload(codexSchema.V2ThreadTokenUsageUpdatedNotification, event.payload); const normalizedUsage = payload ? normalizeCodexTokenUsage(payload.tokenUsage) : undefined; if (!normalizedUsage) { return []; @@ -1588,7 +1587,7 @@ function mapToRuntimeEvents( } if (event.method === "turn/completed") { - const payload = readPayload(EffectCodexSchema.V2TurnCompletedNotification, event.payload); + const payload = readPayload(codexSchema.V2TurnCompletedNotification, event.payload); if (!payload) { return []; } @@ -1618,7 +1617,7 @@ function mapToRuntimeEvents( } if (event.method === "turn/plan/updated") { - const payload = readPayload(EffectCodexSchema.V2TurnPlanUpdatedNotification, event.payload); + const payload = readPayload(codexSchema.V2TurnPlanUpdatedNotification, event.payload); if (!payload) { return []; } @@ -1639,7 +1638,7 @@ function mapToRuntimeEvents( } if (event.method === "turn/diff/updated") { - const payload = readPayload(EffectCodexSchema.V2TurnDiffUpdatedNotification, event.payload); + const payload = readPayload(codexSchema.V2TurnDiffUpdatedNotification, event.payload); if (!payload) { return []; } @@ -1655,12 +1654,12 @@ function mapToRuntimeEvents( } if (event.method === "item/started") { - const started = mapItemLifecycle(event, canonicalThreadId, "item.started"); + const started = mapItemLifecycle(codexSchema, event, canonicalThreadId, "item.started"); return started ? [started] : []; } if (event.method === "item/completed") { - const payload = readPayload(EffectCodexSchema.V2ItemCompletedNotification, event.payload); + const payload = readPayload(codexSchema.V2ItemCompletedNotification, event.payload); const item = payload?.item; if (!item) { return []; @@ -1702,7 +1701,7 @@ function mapToRuntimeEvents( }, ]; } - const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed"); + const completed = mapItemLifecycle(codexSchema, event, canonicalThreadId, "item.completed"); if (!completed || itemType !== "context_compaction") { return completed ? [completed] : []; } @@ -1735,7 +1734,7 @@ function mapToRuntimeEvents( } if (event.method === "item/plan/delta") { - const payload = readPayload(EffectCodexSchema.V2PlanDeltaNotification, event.payload); + const payload = readPayload(codexSchema.V2PlanDeltaNotification, event.payload); const delta = event.textDelta ?? payload?.delta; if (!delta || delta.length === 0) { return []; @@ -1752,7 +1751,7 @@ function mapToRuntimeEvents( } if (event.method === "item/agentMessage/delta") { - const payload = readPayload(EffectCodexSchema.V2AgentMessageDeltaNotification, event.payload); + const payload = readPayload(codexSchema.V2AgentMessageDeltaNotification, event.payload); const delta = event.textDelta ?? payload?.delta; if (!delta || delta.length === 0) { return []; @@ -1771,7 +1770,7 @@ function mapToRuntimeEvents( if (event.method === "item/commandExecution/outputDelta") { const payload = readPayload( - EffectCodexSchema.V2CommandExecutionOutputDeltaNotification, + codexSchema.V2CommandExecutionOutputDeltaNotification, event.payload, ); const delta = event.textDelta ?? payload?.delta; @@ -1791,10 +1790,7 @@ function mapToRuntimeEvents( } if (event.method === "item/fileChange/outputDelta") { - const payload = readPayload( - EffectCodexSchema.V2FileChangeOutputDeltaNotification, - event.payload, - ); + const payload = readPayload(codexSchema.V2FileChangeOutputDeltaNotification, event.payload); const delta = event.textDelta ?? payload?.delta; if (!delta || delta.length === 0) { return []; @@ -1812,10 +1808,7 @@ function mapToRuntimeEvents( } if (event.method === "item/reasoning/summaryTextDelta") { - const payload = readPayload( - EffectCodexSchema.V2ReasoningSummaryTextDeltaNotification, - event.payload, - ); + const payload = readPayload(codexSchema.V2ReasoningSummaryTextDeltaNotification, event.payload); const delta = event.textDelta ?? payload?.delta; if (!delta || delta.length === 0) { return []; @@ -1834,7 +1827,7 @@ function mapToRuntimeEvents( } if (event.method === "item/reasoning/textDelta") { - const payload = readPayload(EffectCodexSchema.V2ReasoningTextDeltaNotification, event.payload); + const payload = readPayload(codexSchema.V2ReasoningTextDeltaNotification, event.payload); const delta = event.textDelta ?? payload?.delta; if (!delta || delta.length === 0) { return []; @@ -1853,7 +1846,7 @@ function mapToRuntimeEvents( } if (event.method === "item/mcpToolCall/progress") { - const payload = readPayload(EffectCodexSchema.V2McpToolCallProgressNotification, event.payload); + const payload = readPayload(codexSchema.V2McpToolCallProgressNotification, event.payload); if (!payload) { return []; } @@ -1869,10 +1862,7 @@ function mapToRuntimeEvents( } if (event.method === "serverRequest/resolved") { - const payload = readPayload( - EffectCodexSchema.V2ServerRequestResolvedNotification, - event.payload, - ); + const payload = readPayload(codexSchema.V2ServerRequestResolvedNotification, event.payload); if (!payload) { return []; } @@ -1890,7 +1880,7 @@ function mapToRuntimeEvents( } if (event.method === "item/tool/requestUserInput/answered") { - const payload = readPayload(EffectCodexSchema.ToolRequestUserInputResponse, event.payload); + const payload = readPayload(codexSchema.ToolRequestUserInputResponse, event.payload); if (!payload) { return []; } @@ -1906,7 +1896,7 @@ function mapToRuntimeEvents( } if (event.method === "model/rerouted") { - const payload = readPayload(EffectCodexSchema.V2ModelReroutedNotification, event.payload); + const payload = readPayload(codexSchema.V2ModelReroutedNotification, event.payload); if (!payload) { return []; } @@ -1924,7 +1914,7 @@ function mapToRuntimeEvents( } if (event.method === "deprecationNotice") { - const payload = readPayload(EffectCodexSchema.V2DeprecationNoticeNotification, event.payload); + const payload = readPayload(codexSchema.V2DeprecationNoticeNotification, event.payload); if (!payload) { return []; } @@ -1941,7 +1931,7 @@ function mapToRuntimeEvents( } if (event.method === "configWarning") { - const payload = readPayload(EffectCodexSchema.V2ConfigWarningNotification, event.payload); + const payload = readPayload(codexSchema.V2ConfigWarningNotification, event.payload); if (!payload) { return []; } @@ -1962,7 +1952,7 @@ function mapToRuntimeEvents( } if (event.method === "account/updated") { - if (!readPayload(EffectCodexSchema.V2AccountUpdatedNotification, event.payload)) { + if (!readPayload(codexSchema.V2AccountUpdatedNotification, event.payload)) { return []; } return [ @@ -1977,10 +1967,7 @@ function mapToRuntimeEvents( } if (event.method === "account/rateLimits/updated") { - const payload = readPayload( - EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, - event.payload, - ); + const payload = readPayload(codexSchema.V2AccountRateLimitsUpdatedNotification, event.payload); const limits = payload ? codexRateLimitsToUpdate(payload.rateLimits) : undefined; if (!limits) { return []; @@ -1996,7 +1983,7 @@ function mapToRuntimeEvents( if (event.method === "mcpServer/oauthLogin/completed") { const payload = readPayload( - EffectCodexSchema.V2McpServerOauthLoginCompletedNotification, + codexSchema.V2McpServerOauthLoginCompletedNotification, event.payload, ); if (!payload) { @@ -2016,10 +2003,7 @@ function mapToRuntimeEvents( } if (event.method === "thread/realtime/started") { - const payload = readPayload( - EffectCodexSchema.V2ThreadRealtimeStartedNotification, - event.payload, - ); + const payload = readPayload(codexSchema.V2ThreadRealtimeStartedNotification, event.payload); if (!payload) { return []; } @@ -2035,10 +2019,7 @@ function mapToRuntimeEvents( } if (event.method === "thread/realtime/itemAdded") { - const payload = readPayload( - EffectCodexSchema.V2ThreadRealtimeItemAddedNotification, - event.payload, - ); + const payload = readPayload(codexSchema.V2ThreadRealtimeItemAddedNotification, event.payload); if (!payload) { return []; } @@ -2055,7 +2036,7 @@ function mapToRuntimeEvents( if (event.method === "thread/realtime/outputAudio/delta") { const payload = readPayload( - EffectCodexSchema.V2ThreadRealtimeOutputAudioDeltaNotification, + codexSchema.V2ThreadRealtimeOutputAudioDeltaNotification, event.payload, ); if (!payload) { @@ -2073,7 +2054,7 @@ function mapToRuntimeEvents( } if (event.method === "thread/realtime/error") { - const payload = readPayload(EffectCodexSchema.V2ThreadRealtimeErrorNotification, event.payload); + const payload = readPayload(codexSchema.V2ThreadRealtimeErrorNotification, event.payload); const message = payload?.message ?? event.message ?? "Realtime error"; return [ { @@ -2087,10 +2068,7 @@ function mapToRuntimeEvents( } if (event.method === "thread/realtime/closed") { - const payload = readPayload( - EffectCodexSchema.V2ThreadRealtimeClosedNotification, - event.payload, - ); + const payload = readPayload(codexSchema.V2ThreadRealtimeClosedNotification, event.payload); return [ { type: "thread.realtime.closed", @@ -2103,7 +2081,7 @@ function mapToRuntimeEvents( } if (event.method === "error") { - const payload = readPayload(EffectCodexSchema.V2ErrorNotification, event.payload); + const payload = readPayload(codexSchema.V2ErrorNotification, event.payload); const message = payload?.error.message ?? event.message ?? "Provider runtime error"; const willRetry = payload?.willRetry === true; return [ @@ -2145,7 +2123,7 @@ function mapToRuntimeEvents( } if (event.method === "windows/worldWritableWarning") { - if (!readPayload(EffectCodexSchema.V2WindowsWorldWritableWarningNotification, event.payload)) { + if (!readPayload(codexSchema.V2WindowsWorldWritableWarningNotification, event.payload)) { return []; } return [ @@ -2162,7 +2140,7 @@ function mapToRuntimeEvents( if (event.method === "windowsSandbox/setupCompleted") { const payload = readPayload( - EffectCodexSchema.V2WindowsSandboxSetupCompletedNotification, + codexSchema.V2WindowsSandboxSetupCompletedNotification, event.payload, ); if (!payload) { @@ -2240,6 +2218,20 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }); } + const codexSchema = yield* Effect.tryPromise({ + try: async (signal) => { + const schema = await import("effect-codex-app-server/schema"); + signal.throwIfAborted(); + return schema; + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to load the Codex protocol schema.", + cause, + }), + }); const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { yield* Effect.suspend(() => stopSessionInternal(existing)); @@ -2318,7 +2310,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } } else if (event.method === "thread/tokenUsage/updated") { const payload = readPayload( - EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification, + codexSchema.V2ThreadTokenUsageUpdatedNotification, event.payload, ); if (payload) { @@ -2339,35 +2331,37 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } } - const runtimeEvents = mapToRuntimeEvents(event, event.threadId).map((runtimeEvent) => { - if (runtimeEvent.type === "turn.completed" && runtimeEvent.turnId) { - return { - ...runtimeEvent, - payload: { - ...runtimeEvent.payload, - tokenUsage: completeCodexTurnTokenUsage( - turnTokenUsage, - String(runtimeEvent.turnId), - runtimeEvent.payload.state === "completed", - ), - }, - } satisfies ProviderRuntimeEvent; - } - if (runtimeEvent.type === "turn.aborted" && runtimeEvent.turnId) { - return { - ...runtimeEvent, - payload: { - ...runtimeEvent.payload, - tokenUsage: completeCodexTurnTokenUsage( - turnTokenUsage, - String(runtimeEvent.turnId), - false, - ), - }, - } satisfies ProviderRuntimeEvent; - } - return runtimeEvent; - }); + const runtimeEvents = mapToRuntimeEvents(codexSchema, event, event.threadId).map( + (runtimeEvent) => { + if (runtimeEvent.type === "turn.completed" && runtimeEvent.turnId) { + return { + ...runtimeEvent, + payload: { + ...runtimeEvent.payload, + tokenUsage: completeCodexTurnTokenUsage( + turnTokenUsage, + String(runtimeEvent.turnId), + runtimeEvent.payload.state === "completed", + ), + }, + } satisfies ProviderRuntimeEvent; + } + if (runtimeEvent.type === "turn.aborted" && runtimeEvent.turnId) { + return { + ...runtimeEvent, + payload: { + ...runtimeEvent.payload, + tokenUsage: completeCodexTurnTokenUsage( + turnTokenUsage, + String(runtimeEvent.turnId), + false, + ), + }, + } satisfies ProviderRuntimeEvent; + } + return runtimeEvent; + }, + ); if (runtimeEvents.length === 0) { yield* Effect.logDebug("ignoring unhandled Codex provider event", { method: event.method, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index d65a09c6d4f9..29e09bdbdc3d 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -9,8 +9,8 @@ import * as Scope from "effect/Scope"; import * as Types from "effect/Types"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import * as CodexClient from "effect-codex-app-server/client"; -import * as CodexSchema from "effect-codex-app-server/schema"; +import type * as CodexClient from "effect-codex-app-server/client"; +import type * as CodexSchema from "effect-codex-app-server/schema"; import * as CodexErrors from "effect-codex-app-server/errors"; import type { @@ -356,6 +356,18 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu readonly cwd: string; readonly environment?: NodeJS.ProcessEnv | undefined; }) { + const codexClient = yield* Effect.tryPromise({ + try: async (signal) => { + const client = await import("effect-codex-app-server/client"); + signal.throwIfAborted(); + return client; + }, + catch: (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + }); // `~` is not shell-expanded when env vars are set via `child_process.spawn`, // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip // "CODEX_HOME points to '~/.codex_work', but that path does not exist". @@ -390,8 +402,8 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu }), ), ); - const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); - const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + const clientContext = yield* Layer.build(codexClient.layerChildProcess(child)); + const client = yield* Effect.service(codexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), ); const initialize = yield* client.request("initialize", buildCodexInitializeParams()); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index fd4d66dd497b..3a5fc8d752bd 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -67,9 +67,9 @@ function makeThreadOpenResponse( } describe("buildTurnStartParams", () => { - it("keeps invalid turn values only in the schema cause", () => { + it("keeps invalid turn values only in the schema cause", async () => { const secret = "codex-turn-input-secret-sentinel"; - const error = Effect.runSync( + const error = await Effect.runPromise( buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "full-access", @@ -93,8 +93,8 @@ describe("buildTurnStartParams", () => { NodeAssert.doesNotMatch(JSON.stringify(directDiagnostics), new RegExp(secret)); }); - it("includes plan collaboration mode when requested", () => { - const params = Effect.runSync( + it("includes plan collaboration mode when requested", async () => { + const params = await Effect.runPromise( buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "full-access", @@ -134,8 +134,8 @@ describe("buildTurnStartParams", () => { }); }); - it("includes default collaboration mode and image attachments", () => { - const params = Effect.runSync( + it("includes default collaboration mode and image attachments", async () => { + const params = await Effect.runPromise( buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "auto-accept-edits", @@ -183,8 +183,8 @@ describe("buildTurnStartParams", () => { }); }); - it("reports the same fallback model and effort in settings and instructions", () => { - const params = Effect.runSync( + it("reports the same fallback model and effort in settings and instructions", async () => { + const params = await Effect.runPromise( buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "full-access", @@ -224,8 +224,8 @@ describe("buildTurnStartParams", () => { }), ); - it("omits collaboration mode when interaction mode is absent", () => { - const params = Effect.runSync( + it("omits collaboration mode when interaction mode is absent", async () => { + const params = await Effect.runPromise( buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "approval-required", diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 4b88b7ce01c0..25748cec6270 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -31,16 +31,14 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import * as CodexClient from "effect-codex-app-server/client"; import * as CodexErrors from "effect-codex-app-server/errors"; -import * as CodexRpc from "effect-codex-app-server/rpc"; -import * as EffectCodexSchema from "effect-codex-app-server/schema"; +import type * as CodexRpc from "effect-codex-app-server/rpc"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; -const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); const PROVIDER = ProviderDriverKind.make("codex"); @@ -127,16 +125,6 @@ const McpElicitationForm = Schema.Struct({ const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); const isMcpElicitationForm = Schema.is(McpElicitationForm); -// TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated -// `V2TurnStartParams` schema includes `collaborationMode` directly. -const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartParams.pipe( - Schema.fieldsAssign({ - collaborationMode: Schema.optionalKey(EffectCodexSchema.V2TurnStartParams__CollaborationMode), - }), -); -const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( - CodexTurnStartParamsWithCollaborationMode, -); const CodexChildResumeMetadata = Schema.Struct({ thread: Schema.Struct({ id: Schema.String }), model: Schema.String, @@ -144,8 +132,12 @@ const CodexChildResumeMetadata = Schema.Struct({ }); const decodeCodexChildResumeMetadata = Schema.decodeUnknownEffect(CodexChildResumeMetadata); -export type CodexTurnStartParamsWithCollaborationMode = - typeof CodexTurnStartParamsWithCollaborationMode.Type; +export type CodexTurnStartParamsWithCollaborationMode = Omit< + EffectCodexSchema.V2TurnStartParams, + "collaborationMode" +> & { + readonly collaborationMode?: EffectCodexSchema.V2TurnStartParams__CollaborationMode; +}; export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; type CodexServiceTier = NonNullable; @@ -627,7 +619,7 @@ export function buildTurnStartParams(input: { browserToolsAvailable: input.browserToolsAvailable ?? true, }); - return decodeCodexTurnStartParamsWithCollaborationMode({ + const params = { threadId: input.threadId, input: turnInput, approvalPolicy: config.approvalPolicy, @@ -637,15 +629,37 @@ export function buildTurnStartParams(input: { ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(collaborationMode ? { collaborationMode } : {}), - }).pipe( - Effect.mapError((cause) => - CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( - "decode-request-payload", - cause, - { method: "turn/start" }, + }; + return Effect.gen(function* () { + const codexSchema = yield* Effect.tryPromise({ + try: async (signal) => { + const schema = await import("effect-codex-app-server/schema"); + signal.throwIfAborted(); + return schema; + }, + catch: (cause) => + new CodexErrors.CodexAppServerProtocolParseError({ + operation: "decode-request-payload", + method: "turn/start", + cause, + }), + }); + // The generated turn schema does not yet include collaborationMode. + const turnSchema = codexSchema.V2TurnStartParams.pipe( + Schema.fieldsAssign({ + collaborationMode: Schema.optionalKey(codexSchema.V2TurnStartParams__CollaborationMode), + }), + ); + return yield* Schema.decodeUnknownEffect(turnSchema)(params).pipe( + Effect.mapError((cause) => + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-request-payload", + cause, + { method: "turn/start" }, + ), ), - ), - ); + ); + }); } function classifyCodexStderrLine(rawLine: string): { readonly message: string } | null { @@ -1158,6 +1172,23 @@ export const makeCodexSessionRuntime = ( ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { + const [codexClient, codexRpc, codexSchema] = yield* Effect.tryPromise({ + try: async (signal) => { + const modules = await Promise.all([ + import("effect-codex-app-server/client"), + import("effect-codex-app-server/rpc"), + import("effect-codex-app-server/schema"), + ]); + signal.throwIfAborted(); + return modules; + }, + catch: (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${options.binaryPath} app-server`, + cause, + }), + }); + const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(codexSchema.V2TurnStartResponse); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; const crypto = yield* Crypto.Crypto; @@ -1208,11 +1239,10 @@ export const makeCodexSessionRuntime = ( ), ); - const clientContext = yield* CodexClient.layerChildProcess(child).pipe( - Layer.build, - Effect.provideService(Scope.Scope, runtimeScope), - ); - const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + const clientContext = yield* codexClient + .layerChildProcess(child) + .pipe(Layer.build, Effect.provideService(Scope.Scope, runtimeScope)); + const client = yield* Effect.service(codexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), ); const serverNotifications = yield* Queue.unbounded(); @@ -2158,7 +2188,7 @@ export const makeCodexSessionRuntime = ( yield* Effect.forEach( Object.values( - CodexRpc.SERVER_NOTIFICATION_METHODS, + codexRpc.SERVER_NOTIFICATION_METHODS, ) as ReadonlyArray, registerServerNotification, { concurrency: 1, discard: true }, diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b5894192eed9..320078a84363 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -16,7 +16,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import * as EffectAcpClient from "effect-acp/client"; +import type * as EffectAcpClient from "effect-acp/client"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; @@ -327,6 +327,18 @@ export const make = ( ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { + const acpClient = yield* Effect.tryPromise({ + try: async (signal) => { + const client = await import("effect-acp/client"); + signal.throwIfAborted(); + return client; + }, + catch: (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to load the ACP client runtime.", + cause, + }), + }); const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; @@ -470,7 +482,7 @@ export const make = ( ); const acpContext = yield* Layer.build( - EffectAcpClient.layerChildProcess(child, { + acpClient.layerChildProcess(child, { ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}), ...(options.transformSessionUpdate ? { transformSessionUpdate: options.transformSessionUpdate } @@ -486,7 +498,7 @@ export const make = ( }), ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); - const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); + const acp = yield* Effect.service(acpClient.AcpClient).pipe(Effect.provide(acpContext)); const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) => handleSessionUpdate({ diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8cafbd5009b4..86488e1262fd 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -51,6 +51,12 @@ const managedAuthShellModule = : import("./components/clerk/BrowserManagedAuthShell") : null; +// The desktop landing opens a draft after the first environment snapshot. +// Load its editor while authentication and backend startup are still pending. +if (isElectron && history.location.pathname === "/") { + void import("./components/ChatView").catch(() => {}); +} + // The index.html boot splash lives inside #root, and React's first commit // clears it. Resolve everything that first commit needs, the selected // managed-auth runtime and the initial route's split chunks, before diff --git a/packages/contracts/package.json b/packages/contracts/package.json index dbd8ee3744e8..4e1c98789549 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -6,6 +6,7 @@ "dist" ], "type": "module", + "sideEffects": false, "exports": { ".": { "types": "./src/index.ts", From eec1e06add81277732139627655f342ebbf321ac Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 06:43:24 +0000 Subject: [PATCH 6/6] test(server): wait for deferred provider probe signals --- .../provider/Layers/ProviderRegistry.test.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index cf3fe15ea9a5..2ace77ba1617 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -254,7 +254,7 @@ function failingSpawnerLayer(description: string) { ); } -function hangingScopedSpawnerLayer(killCalls: Ref.Ref) { +function hangingScopedSpawnerLayer(killCalls: Ref.Ref, started: Deferred.Deferred) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => @@ -273,6 +273,7 @@ function hangingScopedSpawnerLayer(killCalls: Ref.Ref) { getOutputFd: () => Stream.empty, }); yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + yield* Deferred.succeed(started, undefined); return handle; }), ), @@ -534,12 +535,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te it.effect("closes the app-server probe scope when provider status times out", () => Effect.gen(function* () { const killCalls = yield* Ref.make(0); + const started = yield* Deferred.make(); const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( - Effect.provide(hangingScopedSpawnerLayer(killCalls)), + Effect.provide(hangingScopedSpawnerLayer(killCalls, started)), Effect.forkChild, ); - yield* Effect.yieldNow; + yield* Deferred.await(started); yield* TestClock.adjust("11 seconds"); yield* Effect.yieldNow; @@ -2231,15 +2233,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; + const failedProbe = yield* Stream.toPull( + registry.streamChanges.pipe( + Stream.filter((providers) => + providers.some( + (provider) => + provider.instanceId === "codex_personal" && provider.status === "error", + ), + ), + ), + ); let providers = yield* registry.getProviders; - for ( - let attempts = 0; - attempts < 50 && + if ( providers.find((provider) => provider.instanceId === "codex_personal")?.status !== - "error"; - attempts += 1 + "error" ) { - yield* Effect.yieldNow; + yield* failedProbe; providers = yield* registry.getProviders; } const codexPersonal = providers.find(