authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-13 15:29:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-13 15:29:08-07:00
log008ac5c3e5c81d297bdb7fc70bd04b7f69ca36c6
tree530097e86fb63763f2484156fb9159138d3c3231
parent9dcea68bce25e1c30be7a6439bfe607b236740a0
signature Signed by PGP key B5690EEEBB952194

Cache provider status and gate desktop startup (#1962)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: codex <codex@users.noreply.github.com>

18 files changed, 1010 insertions(+), 91 deletions(-)

apps/desktop/src/backendReadiness.test.ts+32-1
...@@ -7,7 +7,7 @@ import {...@@ -7,7 +7,7 @@ import {
7} from "./backendReadiness";7} from "./backendReadiness";
88
9describe("waitForHttpReady", () => {9describe("waitForHttpReady", () => {
10 it("returns once the backend reports a successful session endpoint", async () => {10 it("returns once the backend serves the requested readiness path", async () => {
11 const fetchImpl = vi11 const fetchImpl = vi
12 .fn<typeof fetch>()12 .fn<typeof fetch>()
13 .mockResolvedValueOnce(new Response(null, { status: 503 }))13 .mockResolvedValueOnce(new Response(null, { status: 503 }))
...@@ -20,6 +20,11 @@ describe("waitForHttpReady", () => {...@@ -20,6 +20,11 @@ describe("waitForHttpReady", () => {
20 });20 });
2121
22 expect(fetchImpl).toHaveBeenCalledTimes(2);22 expect(fetchImpl).toHaveBeenCalledTimes(2);
23 expect(fetchImpl).toHaveBeenNthCalledWith(
24 1,
25 "http://127.0.0.1:3773/",
26 expect.objectContaining({ redirect: "manual" }),
27 );
23 });28 });
2429
25 it("retries after a readiness request stalls past the per-request timeout", async () => {30 it("retries after a readiness request stalls past the per-request timeout", async () => {
...@@ -80,4 +85,30 @@ describe("waitForHttpReady", () => {...@@ -80,4 +85,30 @@ describe("waitForHttpReady", () => {
80 expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true);85 expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true);
81 expect(isBackendReadinessAborted(new Error("nope"))).toBe(false);86 expect(isBackendReadinessAborted(new Error("nope"))).toBe(false);
82 });87 });
88
89 it("supports custom readiness predicates", async () => {
90 const fetchImpl = vi
91 .fn<typeof fetch>()
92 .mockResolvedValueOnce(new Response(null, { status: 200 }))
93 .mockResolvedValueOnce(new Response(null, { status: 204 }));
94
95 await waitForHttpReady("http://127.0.0.1:3773", {
96 fetchImpl,
97 timeoutMs: 1_000,
98 intervalMs: 0,
99 path: "/api/healthz",
100 isReady: (response) => response.status === 204,
101 });
102
103 expect(fetchImpl).toHaveBeenNthCalledWith(
104 1,
105 "http://127.0.0.1:3773/api/healthz",
106 expect.objectContaining({ redirect: "manual" }),
107 );
108 expect(fetchImpl).toHaveBeenNthCalledWith(
109 2,
110 "http://127.0.0.1:3773/api/healthz",
111 expect.objectContaining({ redirect: "manual" }),
112 );
113 });
83});114});
apps/desktop/src/backendReadiness.ts+6-2
...@@ -4,6 +4,8 @@ export interface WaitForHttpReadyOptions {...@@ -4,6 +4,8 @@ export interface WaitForHttpReadyOptions {
4 readonly requestTimeoutMs?: number;4 readonly requestTimeoutMs?: number;
5 readonly fetchImpl?: typeof fetch;5 readonly fetchImpl?: typeof fetch;
6 readonly signal?: AbortSignal;6 readonly signal?: AbortSignal;
7 readonly path?: string;
8 readonly isReady?: (response: Response) => boolean;
7}9}
810
9const DEFAULT_TIMEOUT_MS = 30_000;11const DEFAULT_TIMEOUT_MS = 30_000;
...@@ -57,6 +59,8 @@ export async function waitForHttpReady(...@@ -57,6 +59,8 @@ export async function waitForHttpReady(
57 const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;59 const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
58 const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;60 const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
59 const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;61 const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
62 const readinessPath = options?.path ?? "/";
63 const isReady = options?.isReady ?? ((response: Response) => response.ok);
60 const deadline = Date.now() + timeoutMs;64 const deadline = Date.now() + timeoutMs;
6165
62 for (;;) {66 for (;;) {
...@@ -74,11 +78,11 @@ export async function waitForHttpReady(...@@ -74,11 +78,11 @@ export async function waitForHttpReady(
74 signal?.addEventListener("abort", abortRequest, { once: true });78 signal?.addEventListener("abort", abortRequest, { once: true });
7579
76 try {80 try {
77 const response = await fetchImpl(`${baseUrl}/api/auth/session`, {81 const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), {
78 redirect: "manual",82 redirect: "manual",
79 signal: requestController.signal,83 signal: requestController.signal,
80 });84 });
81 if (response.ok) {85 if (isReady(response)) {
82 return;86 return;
83 }87 }
84 } catch (error) {88 } catch (error) {
apps/desktop/src/main.ts+136-32
...@@ -55,6 +55,7 @@ import { showDesktopConfirmDialog } from "./confirmDialog";...@@ -55,6 +55,7 @@ import { showDesktopConfirmDialog } from "./confirmDialog";
55import { resolveDesktopServerExposure } from "./serverExposure";55import { resolveDesktopServerExposure } from "./serverExposure";
56import { syncShellEnvironment } from "./syncShellEnvironment";56import { syncShellEnvironment } from "./syncShellEnvironment";
57import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";57import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
58import { ServerListeningDetector } from "./serverListeningDetector";
58import {59import {
59 createInitialDesktopUpdateState,60 createInitialDesktopUpdateState,
60 reduceDesktopUpdateStateOnCheckFailure,61 reduceDesktopUpdateStateOnCheckFailure,
...@@ -144,6 +145,8 @@ let backendWsUrl = "";...@@ -144,6 +145,8 @@ let backendWsUrl = "";
144let backendEndpointUrl: string | null = null;145let backendEndpointUrl: string | null = null;
145let backendAdvertisedHost: string | null = null;146let backendAdvertisedHost: string | null = null;
146let backendReadinessAbortController: AbortController | null = null;147let backendReadinessAbortController: AbortController | null = null;
148let backendInitialWindowOpenInFlight: Promise<void> | null = null;
149let backendListeningDetector: ServerListeningDetector | null = null;
147let restartAttempt = 0;150let restartAttempt = 0;
148let restartTimer: ReturnType<typeof setTimeout> | null = null;151let restartTimer: ReturnType<typeof setTimeout> | null = null;
149let isQuitting = false;152let isQuitting = false;
...@@ -362,13 +365,17 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null {...@@ -362,13 +365,17 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null {
362 return null;365 return null;
363}366}
364367
365async function waitForBackendHttpReady(baseUrl: string): Promise<void> {368async function waitForBackendHttpReady(
369 baseUrl: string,
370 options?: Parameters<typeof waitForHttpReady>[1],
371): Promise<void> {
366 cancelBackendReadinessWait();372 cancelBackendReadinessWait();
367 const controller = new AbortController();373 const controller = new AbortController();
368 backendReadinessAbortController = controller;374 backendReadinessAbortController = controller;
369375
370 try {376 try {
371 await waitForHttpReady(baseUrl, {377 await waitForHttpReady(baseUrl, {
378 ...options,
372 signal: controller.signal,379 signal: controller.signal,
373 });380 });
374 } finally {381 } finally {
...@@ -383,6 +390,88 @@ function cancelBackendReadinessWait(): void {...@@ -383,6 +390,88 @@ function cancelBackendReadinessWait(): void {
383 backendReadinessAbortController = null;390 backendReadinessAbortController = null;
384}391}
385392
393async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> {
394 const httpReadyPromise = waitForBackendHttpReady(baseUrl, {
395 timeoutMs: 60_000,
396 });
397 const listeningPromise = backendListeningDetector?.promise;
398
399 if (!listeningPromise) {
400 await httpReadyPromise;
401 return "http";
402 }
403
404 return await new Promise<"listening" | "http">((resolve, reject) => {
405 let settled = false;
406
407 const settleResolve = (source: "listening" | "http") => {
408 if (settled) {
409 return;
410 }
411 settled = true;
412 if (source === "listening") {
413 cancelBackendReadinessWait();
414 }
415 resolve(source);
416 };
417
418 const settleReject = (error: unknown) => {
419 if (settled) {
420 return;
421 }
422 settled = true;
423 reject(error);
424 };
425
426 listeningPromise.then(
427 () => settleResolve("listening"),
428 (error) => settleReject(error),
429 );
430 httpReadyPromise.then(
431 () => settleResolve("http"),
432 (error) => {
433 if (settled && isBackendReadinessAborted(error)) {
434 return;
435 }
436 settleReject(error);
437 },
438 );
439 });
440}
441
442function ensureInitialBackendWindowOpen(): void {
443 const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
444 if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) {
445 return;
446 }
447
448 const nextOpen = waitForBackendWindowReady(backendHttpUrl)
449 .then((source) => {
450 writeDesktopLogHeader(`bootstrap backend ready source=${source}`);
451 if (mainWindow ?? BrowserWindow.getAllWindows()[0]) {
452 return;
453 }
454 mainWindow = createWindow();
455 writeDesktopLogHeader("bootstrap main window created");
456 })
457 .catch((error) => {
458 if (isBackendReadinessAborted(error)) {
459 return;
460 }
461 writeDesktopLogHeader(
462 `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
463 );
464 console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error);
465 })
466 .finally(() => {
467 if (backendInitialWindowOpenInFlight === nextOpen) {
468 backendInitialWindowOpenInFlight = null;
469 }
470 });
471
472 backendInitialWindowOpenInFlight = nextOpen;
473}
474
386function writeDesktopStreamChunk(475function writeDesktopStreamChunk(
387 streamName: "stdout" | "stderr",476 streamName: "stdout" | "stderr",
388 chunk: unknown,477 chunk: unknown,
...@@ -460,14 +549,16 @@ function initializePackagedLogging(): void {...@@ -460,14 +549,16 @@ function initializePackagedLogging(): void {
460}549}
461550
462function captureBackendOutput(child: ChildProcess.ChildProcess): void {551function captureBackendOutput(child: ChildProcess.ChildProcess): void {
463 if (!app.isPackaged || backendLogSink === null) return;552 const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => {
464 const writeChunk = (chunk: unknown): void => {553 stream?.on("data", (chunk: unknown) => {
465 if (!backendLogSink) return;554 const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
466 const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");555 backendLogSink?.write(buffer);
467 backendLogSink.write(buffer);556 backendListeningDetector?.push(buffer);
557 });
468 };558 };
469 child.stdout?.on("data", writeChunk);559
470 child.stderr?.on("data", writeChunk);560 attachStream(child.stdout);
561 attachStream(child.stderr);
471}562}
472563
473initializePackagedLogging();564initializePackagedLogging();
...@@ -1222,7 +1313,7 @@ function startBackend(): void {...@@ -1222,7 +1313,7 @@ function startBackend(): void {
1222 return;1313 return;
1223 }1314 }
12241315
1225 const captureBackendLogs = app.isPackaged && backendLogSink !== null;1316 const captureBackendLogs = !isDevelopment;
1226 const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {1317 const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
1227 cwd: resolveBackendCwd(),1318 cwd: resolveBackendCwd(),
1228 // In Electron main, process.execPath points to the Electron binary.1319 // In Electron main, process.execPath points to the Electron binary.
...@@ -1259,6 +1350,8 @@ function startBackend(): void {...@@ -1259,6 +1350,8 @@ function startBackend(): void {
1259 scheduleBackendRestart("missing desktop bootstrap pipe");1350 scheduleBackendRestart("missing desktop bootstrap pipe");
1260 return;1351 return;
1261 }1352 }
1353 const listeningDetector = new ServerListeningDetector();
1354 backendListeningDetector = listeningDetector;
1262 backendProcess = child;1355 backendProcess = child;
1263 let backendSessionClosed = false;1356 let backendSessionClosed = false;
1264 const closeBackendSession = (details: string) => {1357 const closeBackendSession = (details: string) => {
...@@ -1277,6 +1370,10 @@ function startBackend(): void {...@@ -1277,6 +1370,10 @@ function startBackend(): void {
1277 });1370 });
12781371
1279 child.on("error", (error) => {1372 child.on("error", (error) => {
1373 if (backendListeningDetector === listeningDetector) {
1374 listeningDetector.fail(error);
1375 backendListeningDetector = null;
1376 }
1280 const wasExpected = expectedBackendExitChildren.has(child);1377 const wasExpected = expectedBackendExitChildren.has(child);
1281 if (backendProcess === child) {1378 if (backendProcess === child) {
1282 backendProcess = null;1379 backendProcess = null;
...@@ -1289,6 +1386,14 @@ function startBackend(): void {...@@ -1289,6 +1386,14 @@ function startBackend(): void {
1289 });1386 });
12901387
1291 child.on("exit", (code, signal) => {1388 child.on("exit", (code, signal) => {
1389 if (backendListeningDetector === listeningDetector) {
1390 listeningDetector.fail(
1391 new Error(
1392 `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`,
1393 ),
1394 );
1395 backendListeningDetector = null;
1396 }
1292 const wasExpected = expectedBackendExitChildren.has(child);1397 const wasExpected = expectedBackendExitChildren.has(child);
1293 if (backendProcess === child) {1398 if (backendProcess === child) {
1294 backendProcess = null;1399 backendProcess = null;
...@@ -1300,10 +1405,13 @@ function startBackend(): void {...@@ -1300,10 +1405,13 @@ function startBackend(): void {
1300 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;1405 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
1301 scheduleBackendRestart(reason);1406 scheduleBackendRestart(reason);
1302 });1407 });
1408
1409 ensureInitialBackendWindowOpen();
1303}1410}
13041411
1305function stopBackend(): void {1412function stopBackend(): void {
1306 cancelBackendReadinessWait();1413 cancelBackendReadinessWait();
1414 backendListeningDetector = null;
1307 if (restartTimer) {1415 if (restartTimer) {
1308 clearTimeout(restartTimer);1416 clearTimeout(restartTimer);
1309 restartTimer = null;1417 restartTimer = null;
...@@ -1705,7 +1813,7 @@ function createWindow(): BrowserWindow {...@@ -1705,7 +1813,7 @@ function createWindow(): BrowserWindow {
1705 height: 780,1813 height: 780,
1706 minWidth: 840,1814 minWidth: 840,
1707 minHeight: 620,1815 minHeight: 620,
1708 show: isDevelopment,1816 show: false,
1709 autoHideMenuBar: true,1817 autoHideMenuBar: true,
1710 backgroundColor: getInitialWindowBackgroundColor(),1818 backgroundColor: getInitialWindowBackgroundColor(),
1711 ...getIconOption(),1819 ...getIconOption(),
...@@ -1779,20 +1887,23 @@ function createWindow(): BrowserWindow {...@@ -1779,20 +1887,23 @@ function createWindow(): BrowserWindow {
1779 window.setTitle(APP_DISPLAY_NAME);1887 window.setTitle(APP_DISPLAY_NAME);
1780 emitUpdateState();1888 emitUpdateState();
1781 });1889 });
1782 if (!isDevelopment) {1890
1783 window.once("ready-to-show", () => {1891 let initialRevealScheduled = false;
1784 revealWindow(window);1892 const revealInitialWindow = () => {
1785 });1893 if (initialRevealScheduled) {
1786 }1894 return;
1895 }
1896 initialRevealScheduled = true;
1897 revealWindow(window);
1898 };
1899
1900 window.once("ready-to-show", revealInitialWindow);
17871901
1788 if (isDevelopment) {1902 if (isDevelopment) {
1789 void window.loadURL(resolveDesktopDevServerUrl());1903 void window.loadURL(resolveDesktopDevServerUrl());
1790 window.webContents.openDevTools({ mode: "detach" });1904 window.webContents.openDevTools({ mode: "detach" });
1791 setImmediate(() => {
1792 revealWindow(window);
1793 });
1794 } else {1905 } else {
1795 void window.loadURL(resolveDesktopWindowUrl());1906 void window.loadURL(backendHttpUrl);
1796 }1907 }
17971908
1798 window.on("closed", () => {1909 window.on("closed", () => {
...@@ -1804,14 +1915,6 @@ function createWindow(): BrowserWindow {...@@ -1804,14 +1915,6 @@ function createWindow(): BrowserWindow {
1804 return window;1915 return window;
1805}1916}
18061917
1807function resolveDesktopWindowUrl(): string {
1808 if (backendHttpUrl) {
1809 return backendHttpUrl;
1810 }
1811
1812 return `${DESKTOP_SCHEME}://app`;
1813}
1814
1815// Override Electron's userData path before the `ready` event so that1918// Override Electron's userData path before the `ready` event so that
1816// Chromium session data uses a filesystem-friendly directory name.1919// Chromium session data uses a filesystem-friendly directory name.
1817// Must be called synchronously at the top level — before `app.whenReady()`.1920// Must be called synchronously at the top level — before `app.whenReady()`.
...@@ -1885,10 +1988,7 @@ async function bootstrap(): Promise<void> {...@@ -1885,10 +1988,7 @@ async function bootstrap(): Promise<void> {
1885 return;1988 return;
1886 }1989 }
18871990
1888 await waitForBackendHttpReady(backendHttpUrl);1991 ensureInitialBackendWindowOpen();
1889 writeDesktopLogHeader("bootstrap backend ready");
1890 mainWindow = createWindow();
1891 writeDesktopLogHeader("bootstrap main window created");
1892}1992}
18931993
1894app.on("before-quit", () => {1994app.on("before-quit", () => {
...@@ -1922,7 +2022,11 @@ app...@@ -1922,7 +2022,11 @@ app
1922 revealWindow(existingWindow);2022 revealWindow(existingWindow);
1923 return;2023 return;
1924 }2024 }
1925 mainWindow = createWindow();2025 if (isDevelopment) {
2026 mainWindow = createWindow();
2027 return;
2028 }
2029 ensureInitialBackendWindowOpen();
1926 });2030 });
1927 })2031 })
1928 .catch((error) => {2032 .catch((error) => {
apps/desktop/src/serverListeningDetector.test.ts created+31
...@@ -0,0 +1,31 @@
1import { describe, expect, it } from "vitest";
2
3import { ServerListeningDetector } from "./serverListeningDetector";
4
5describe("ServerListeningDetector", () => {
6 it("resolves when the server logs the listening line", async () => {
7 const detector = new ServerListeningDetector();
8
9 detector.push("[01:23:30.571] INFO (#148): Listening on http://0.0.0.0:7011\n");
10
11 await expect(detector.promise).resolves.toBeUndefined();
12 });
13
14 it("resolves when the listening line arrives across multiple chunks", async () => {
15 const detector = new ServerListeningDetector();
16
17 detector.push("[01:23:30.571] INFO (#148): Listen");
18 detector.push("ing on http://0.0.0.0:7011\n");
19
20 await expect(detector.promise).resolves.toBeUndefined();
21 });
22
23 it("rejects when the server exits before logging readiness", async () => {
24 const detector = new ServerListeningDetector();
25 const error = new Error("server exited");
26
27 detector.fail(error);
28
29 await expect(detector.promise).rejects.toBe(error);
30 });
31});
apps/desktop/src/serverListeningDetector.ts created+56
...@@ -0,0 +1,56 @@
1const LISTENING_LOG_FRAGMENT = "Listening on http://";
2const MAX_BUFFER_CHARS = 8_192;
3
4export class ServerListeningDetector {
5 private buffer = "";
6 private settled = false;
7 private readonly resolvePromise: () => void;
8 private readonly rejectPromise: (error: unknown) => void;
9 readonly promise: Promise<void>;
10
11 constructor() {
12 let resolvePromise: (() => void) | null = null;
13 let rejectPromise: ((error: unknown) => void) | null = null;
14
15 this.promise = new Promise<void>((resolve, reject) => {
16 resolvePromise = resolve;
17 rejectPromise = reject;
18 });
19
20 this.resolvePromise = () => {
21 if (this.settled) {
22 return;
23 }
24 this.settled = true;
25 resolvePromise?.();
26 };
27 this.rejectPromise = (error) => {
28 if (this.settled) {
29 return;
30 }
31 this.settled = true;
32 rejectPromise?.(error);
33 };
34 }
35
36 push(chunk: unknown): void {
37 if (this.settled) {
38 return;
39 }
40
41 const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
42 this.buffer = `${this.buffer}${text.replace(/\r/g, "")}`;
43 if (this.buffer.includes(LISTENING_LOG_FRAGMENT)) {
44 this.resolvePromise();
45 return;
46 }
47
48 if (this.buffer.length > MAX_BUFFER_CHARS) {
49 this.buffer = this.buffer.slice(-MAX_BUFFER_CHARS);
50 }
51 }
52
53 fail(error: unknown): void {
54 this.rejectPromise(error);
55 }
56}
apps/server/src/config.ts+4
...@@ -24,6 +24,7 @@ export interface ServerDerivedPaths {...@@ -24,6 +24,7 @@ export interface ServerDerivedPaths {
24 readonly dbPath: string;24 readonly dbPath: string;
25 readonly keybindingsConfigPath: string;25 readonly keybindingsConfigPath: string;
26 readonly settingsPath: string;26 readonly settingsPath: string;
27 readonly providerStatusCacheDir: string;
27 readonly worktreesDir: string;28 readonly worktreesDir: string;
28 readonly attachmentsDir: string;29 readonly attachmentsDir: string;
29 readonly logsDir: string;30 readonly logsDir: string;
...@@ -76,11 +77,13 @@ export const deriveServerPaths = Effect.fn(function* (...@@ -76,11 +77,13 @@ export const deriveServerPaths = Effect.fn(function* (
76 const attachmentsDir = join(stateDir, "attachments");77 const attachmentsDir = join(stateDir, "attachments");
77 const logsDir = join(stateDir, "logs");78 const logsDir = join(stateDir, "logs");
78 const providerLogsDir = join(logsDir, "provider");79 const providerLogsDir = join(logsDir, "provider");
80 const providerStatusCacheDir = join(baseDir, "caches");
79 return {81 return {
80 stateDir,82 stateDir,
81 dbPath,83 dbPath,
82 keybindingsConfigPath: join(stateDir, "keybindings.json"),84 keybindingsConfigPath: join(stateDir, "keybindings.json"),
83 settingsPath: join(stateDir, "settings.json"),85 settingsPath: join(stateDir, "settings.json"),
86 providerStatusCacheDir,
84 worktreesDir: join(baseDir, "worktrees"),87 worktreesDir: join(baseDir, "worktrees"),
85 attachmentsDir,88 attachmentsDir,
86 logsDir,89 logsDir,
...@@ -110,6 +113,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server...@@ -110,6 +113,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server
110 fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }),113 fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }),
111 fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),114 fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),
112 fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),115 fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),
116 fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }),
113 fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),117 fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),
114 fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }),118 fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }),
115 ],119 ],
apps/server/src/http.ts+1
...@@ -226,6 +226,7 @@ export const staticAndDevRouteLayer = HttpRouter.add(...@@ -226,6 +226,7 @@ export const staticAndDevRouteLayer = HttpRouter.add(
226 Effect.gen(function* () {226 Effect.gen(function* () {
227 const request = yield* HttpServerRequest.HttpServerRequest;227 const request = yield* HttpServerRequest.HttpServerRequest;
228 const url = HttpServerRequest.toURL(request);228 const url = HttpServerRequest.toURL(request);
229
229 if (Option.isNone(url)) {230 if (Option.isNone(url)) {
230 return HttpServerResponse.text("Bad Request", { status: 400 });231 return HttpServerResponse.text("Bad Request", { status: 400 });
231 }232 }
apps/server/src/provider/Layers/ClaudeProvider.ts+41
...@@ -663,6 +663,46 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -663,6 +663,46 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
663 });663 });
664});664});
665665
666const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvider => {
667 const checkedAt = new Date().toISOString();
668 const models = providerModelsFromSettings(
669 BUILT_IN_MODELS,
670 PROVIDER,
671 claudeSettings.customModels,
672 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
673 );
674
675 if (!claudeSettings.enabled) {
676 return buildServerProvider({
677 provider: PROVIDER,
678 enabled: false,
679 checkedAt,
680 models,
681 probe: {
682 installed: false,
683 version: null,
684 status: "warning",
685 auth: { status: "unknown" },
686 message: "Claude is disabled in T3 Code settings.",
687 },
688 });
689 }
690
691 return buildServerProvider({
692 provider: PROVIDER,
693 enabled: true,
694 checkedAt,
695 models,
696 probe: {
697 installed: false,
698 version: null,
699 status: "warning",
700 auth: { status: "unknown" },
701 message: "Claude provider status has not been checked in this session yet.",
702 },
703 });
704};
705
666export const ClaudeProviderLive = Layer.effect(706export const ClaudeProviderLive = Layer.effect(
667 ClaudeProvider,707 ClaudeProvider,
668 Effect.gen(function* () {708 Effect.gen(function* () {
...@@ -698,6 +738,7 @@ export const ClaudeProviderLive = Layer.effect(...@@ -698,6 +738,7 @@ export const ClaudeProviderLive = Layer.effect(
698 Stream.map((settings) => settings.providers.claudeAgent),738 Stream.map((settings) => settings.providers.claudeAgent),
699 ),739 ),
700 haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),740 haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
741 initialSnapshot: makePendingClaudeProvider,
701 checkProvider,742 checkProvider,
702 });743 });
703 }),744 }),
apps/server/src/provider/Layers/CodexProvider.ts+41
...@@ -552,6 +552,46 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -552,6 +552,46 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
552 });552 });
553});553});
554554
555const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider => {
556 const checkedAt = new Date().toISOString();
557 const models = providerModelsFromSettings(
558 BUILT_IN_MODELS,
559 PROVIDER,
560 codexSettings.customModels,
561 DEFAULT_CODEX_MODEL_CAPABILITIES,
562 );
563
564 if (!codexSettings.enabled) {
565 return buildServerProvider({
566 provider: PROVIDER,
567 enabled: false,
568 checkedAt,
569 models,
570 probe: {
571 installed: false,
572 version: null,
573 status: "warning",
574 auth: { status: "unknown" },
575 message: "Codex is disabled in T3 Code settings.",
576 },
577 });
578 }
579
580 return buildServerProvider({
581 provider: PROVIDER,
582 enabled: true,
583 checkedAt,
584 models,
585 probe: {
586 installed: false,
587 version: null,
588 status: "warning",
589 auth: { status: "unknown" },
590 message: "Codex provider status has not been checked in this session yet.",
591 },
592 });
593};
594
555export const CodexProviderLive = Layer.effect(595export const CodexProviderLive = Layer.effect(
556 CodexProvider,596 CodexProvider,
557 Effect.gen(function* () {597 Effect.gen(function* () {
...@@ -602,6 +642,7 @@ export const CodexProviderLive = Layer.effect(...@@ -602,6 +642,7 @@ export const CodexProviderLive = Layer.effect(
602 Stream.map((settings) => settings.providers.codex),642 Stream.map((settings) => settings.providers.codex),
603 ),643 ),
604 haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),644 haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
645 initialSnapshot: makePendingCodexProvider,
605 checkProvider,646 checkProvider,
606 });647 });
607 }),648 }),
apps/server/src/provider/Layers/ProviderRegistry.test.ts+65-1
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31} from "./CodexProvider";31} from "./CodexProvider";
32import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider";32import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider";
33import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry";33import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry";
34import { ServerConfig } from "../../config";
34import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings";35import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings";
35import { ProviderRegistry } from "../Services/ProviderRegistry";36import { ProviderRegistry } from "../Services/ProviderRegistry";
3637
...@@ -562,6 +563,61 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -562,6 +563,61 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
562 assert.strictEqual(haveProvidersChanged(providers, [...providers]), false);563 assert.strictEqual(haveProvidersChanged(providers, [...providers]), false);
563 });564 });
564565
566 it.effect("does not probe provider health during registry startup", () =>
567 Effect.gen(function* () {
568 let spawnCount = 0;
569 const serverSettings = yield* makeMutableServerSettingsService();
570 const scope = yield* Scope.make();
571 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
572 const providerRegistryLayer = ProviderRegistryLive.pipe(
573 Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)),
574 Layer.provideMerge(
575 ServerConfig.layerTest(process.cwd(), {
576 prefix: "t3-provider-registry-",
577 }),
578 ),
579 Layer.provideMerge(
580 mockCommandSpawnerLayer((command, args) => {
581 spawnCount += 1;
582 const joined = args.join(" ");
583 if (joined === "--version") {
584 if (command === "codex") {
585 return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
586 }
587 return { stdout: "claude 1.0.0\n", stderr: "", code: 0 };
588 }
589 if (joined === "login status") {
590 return { stdout: "Logged in\n", stderr: "", code: 0 };
591 }
592 if (joined === "auth status") {
593 return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 };
594 }
595 throw new Error(`Unexpected args: ${command} ${joined}`);
596 }),
597 ),
598 );
599 const runtimeServices = yield* Layer.build(
600 Layer.mergeAll(
601 Layer.succeed(ServerSettingsService, serverSettings),
602 providerRegistryLayer,
603 ),
604 ).pipe(Scope.provide(scope));
605
606 yield* Effect.gen(function* () {
607 const registry = yield* ProviderRegistry;
608 assert.deepStrictEqual(yield* registry.getProviders, []);
609 assert.strictEqual(spawnCount, 0);
610
611 const refreshed = yield* registry.refresh("codex");
612 assert.strictEqual(spawnCount > 0, true);
613 assert.strictEqual(
614 refreshed.find((provider) => provider.provider === "codex")?.status,
615 "ready",
616 );
617 }).pipe(Effect.provide(runtimeServices));
618 }),
619 );
620
565 it.effect("reruns codex health when codex provider settings change", () =>621 it.effect("reruns codex health when codex provider settings change", () =>
566 Effect.gen(function* () {622 Effect.gen(function* () {
567 const serverSettings = yield* makeMutableServerSettingsService();623 const serverSettings = yield* makeMutableServerSettingsService();
...@@ -569,6 +625,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -569,6 +625,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
569 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));625 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
570 const providerRegistryLayer = ProviderRegistryLive.pipe(626 const providerRegistryLayer = ProviderRegistryLive.pipe(
571 Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)),627 Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)),
628 Layer.provideMerge(
629 ServerConfig.layerTest(process.cwd(), {
630 prefix: "t3-provider-registry-",
631 }),
632 ),
572 Layer.provideMerge(633 Layer.provideMerge(
573 mockCommandSpawnerLayer((command, args) => {634 mockCommandSpawnerLayer((command, args) => {
574 const joined = args.join(" ");635 const joined = args.join(" ");
...@@ -596,8 +657,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -596,8 +657,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
596 const registry = yield* ProviderRegistry;657 const registry = yield* ProviderRegistry;
597658
598 const initial = yield* registry.getProviders;659 const initial = yield* registry.getProviders;
660 assert.deepStrictEqual(initial, []);
661
662 const refreshed = yield* registry.refresh("codex");
599 assert.strictEqual(663 assert.strictEqual(
600 initial.find((status) => status.provider === "codex")?.status,664 refreshed.find((status) => status.provider === "codex")?.status,
601 "ready",665 "ready",
602 );666 );
603667
apps/server/src/provider/Layers/ProviderRegistry.ts+131-31
...@@ -4,8 +4,9 @@...@@ -4,8 +4,9 @@
4 * @module ProviderRegistryLive4 * @module ProviderRegistryLive
5 */5 */
6import type { ProviderKind, ServerProvider } from "@t3tools/contracts";6import type { ProviderKind, ServerProvider } from "@t3tools/contracts";
7import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect";7import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect";
88
9import { ServerConfig } from "../../config";
9import { ClaudeProviderLive } from "./ClaudeProvider";10import { ClaudeProviderLive } from "./ClaudeProvider";
10import { CodexProviderLive } from "./CodexProvider";11import { CodexProviderLive } from "./CodexProvider";
11import type { ClaudeProviderShape } from "../Services/ClaudeProvider";12import type { ClaudeProviderShape } from "../Services/ClaudeProvider";
...@@ -13,6 +14,14 @@ import { ClaudeProvider } from "../Services/ClaudeProvider";...@@ -13,6 +14,14 @@ import { ClaudeProvider } from "../Services/ClaudeProvider";
13import type { CodexProviderShape } from "../Services/CodexProvider";14import type { CodexProviderShape } from "../Services/CodexProvider";
14import { CodexProvider } from "../Services/CodexProvider";15import { CodexProvider } from "../Services/CodexProvider";
15import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry";16import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry";
17import {
18 hydrateCachedProvider,
19 PROVIDER_CACHE_IDS,
20 orderProviderSnapshots,
21 readProviderStatusCache,
22 resolveProviderStatusCachePath,
23 writeProviderStatusCache,
24} from "../providerStatusCache";
1625
17const loadProviders = (26const loadProviders = (
18 codexProvider: CodexProviderShape,27 codexProvider: CodexProviderShape,
...@@ -32,61 +41,152 @@ export const ProviderRegistryLive = Layer.effect(...@@ -32,61 +41,152 @@ export const ProviderRegistryLive = Layer.effect(
32 Effect.gen(function* () {41 Effect.gen(function* () {
33 const codexProvider = yield* CodexProvider;42 const codexProvider = yield* CodexProvider;
34 const claudeProvider = yield* ClaudeProvider;43 const claudeProvider = yield* ClaudeProvider;
44 const config = yield* ServerConfig;
45 const fileSystem = yield* FileSystem.FileSystem;
46 const path = yield* Path.Path;
35 const changesPubSub = yield* Effect.acquireRelease(47 const changesPubSub = yield* Effect.acquireRelease(
36 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),48 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),
37 PubSub.shutdown,49 PubSub.shutdown,
38 );50 );
39 const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(51 const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider);
40 yield* loadProviders(codexProvider, claudeProvider),52 const cachePathByProvider = new Map(
53 PROVIDER_CACHE_IDS.map(
54 (provider) =>
55 [
56 provider,
57 resolveProviderStatusCachePath({
58 cacheDir: config.providerStatusCacheDir,
59 provider,
60 }),
61 ] as const,
62 ),
63 );
64 const fallbackByProvider = new Map(
65 fallbackProviders.map((provider) => [provider.provider, provider] as const),
41 );66 );
67 const cachedProviders = yield* Effect.forEach(
68 PROVIDER_CACHE_IDS,
69 (provider) => {
70 const filePath = cachePathByProvider.get(provider)!;
71 const fallbackProvider = fallbackByProvider.get(provider)!;
72 return readProviderStatusCache(filePath).pipe(
73 Effect.provideService(FileSystem.FileSystem, fileSystem),
74 Effect.map((cachedProvider) =>
75 cachedProvider === undefined
76 ? undefined
77 : hydrateCachedProvider({
78 cachedProvider,
79 fallbackProvider,
80 }),
81 ),
82 );
83 },
84 { concurrency: "unbounded" },
85 ).pipe(
86 Effect.map((providers) =>
87 orderProviderSnapshots(
88 providers.filter((provider): provider is ServerProvider => provider !== undefined),
89 ),
90 ),
91 );
92 const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders);
93
94 const persistProvider = (provider: ServerProvider) =>
95 writeProviderStatusCache({
96 filePath: cachePathByProvider.get(provider.provider)!,
97 provider,
98 }).pipe(
99 Effect.provideService(FileSystem.FileSystem, fileSystem),
100 Effect.provideService(Path.Path, path),
101 Effect.tapError(Effect.logError),
102 Effect.ignore,
103 );
104
105 const upsertProviders = Effect.fn("upsertProviders")(function* (
106 nextProviders: ReadonlyArray<ServerProvider>,
107 options?: {
108 readonly publish?: boolean;
109 },
110 ) {
111 const [previousProviders, providers] = yield* Ref.modify(
112 providersRef,
113 (previousProviders) => {
114 const mergedProviders = new Map(
115 previousProviders.map((provider) => [provider.provider, provider] as const),
116 );
117
118 for (const provider of nextProviders) {
119 mergedProviders.set(provider.provider, provider);
120 }
42121
43 const syncProviders = Effect.fn("syncProviders")(function* (options?: {122 const providers = orderProviderSnapshots([...mergedProviders.values()]);
44 readonly publish?: boolean;123 return [[previousProviders, providers] as const, providers];
45 }) {124 },
46 const previousProviders = yield* Ref.get(providersRef);125 );
47 const providers = yield* loadProviders(codexProvider, claudeProvider);
48 yield* Ref.set(providersRef, providers);
49126
50 if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) {127 if (haveProvidersChanged(previousProviders, providers)) {
51 yield* PubSub.publish(changesPubSub, providers);128 yield* Effect.forEach(nextProviders, persistProvider, {
129 concurrency: "unbounded",
130 discard: true,
131 });
132 if (options?.publish !== false) {
133 yield* PubSub.publish(changesPubSub, providers);
134 }
52 }135 }
53136
54 return providers;137 return providers;
55 });138 });
56139
57 yield* Stream.runForEach(codexProvider.streamChanges, () => syncProviders()).pipe(140 const syncProvider = Effect.fn("syncProvider")(function* (
58 Effect.forkScoped,141 provider: ServerProvider,
59 );142 options?: {
60 yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe(143 readonly publish?: boolean;
61 Effect.forkScoped,144 },
62 );145 ) {
146 return yield* upsertProviders([provider], options);
147 });
63148
64 const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) {149 const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) {
65 switch (provider) {150 switch (provider) {
66 case "codex":151 case "codex":
67 yield* codexProvider.refresh;152 return yield* codexProvider.refresh.pipe(
68 break;153 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
154 );
69 case "claudeAgent":155 case "claudeAgent":
70 yield* claudeProvider.refresh;156 return yield* claudeProvider.refresh.pipe(
71 break;157 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
158 );
72 default:159 default:
73 yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], {160 return yield* Effect.all(
74 concurrency: "unbounded",161 [
75 });162 codexProvider.refresh.pipe(
76 break;163 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
164 ),
165 claudeProvider.refresh.pipe(
166 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
167 ),
168 ],
169 {
170 concurrency: "unbounded",
171 discard: true,
172 },
173 ).pipe(Effect.andThen(Ref.get(providersRef)));
77 }174 }
78 return yield* syncProviders();
79 });175 });
80176
177 yield* Stream.runForEach(codexProvider.streamChanges, (provider) =>
178 syncProvider(provider),
179 ).pipe(Effect.forkScoped);
180 yield* Stream.runForEach(claudeProvider.streamChanges, (provider) =>
181 syncProvider(provider),
182 ).pipe(Effect.forkScoped);
183
81 return {184 return {
82 getProviders: syncProviders({ publish: false }).pipe(185 getProviders: Ref.get(providersRef),
83 Effect.tapError(Effect.logError),
84 Effect.orElseSucceed(() => []),
85 ),
86 refresh: (provider?: ProviderKind) =>186 refresh: (provider?: ProviderKind) =>
87 refresh(provider).pipe(187 refresh(provider).pipe(
88 Effect.tapError(Effect.logError),188 Effect.tapError(Effect.logError),
89 Effect.orElseSucceed(() => []),189 Effect.orElseSucceed(() => [] as ReadonlyArray<ServerProvider>),
90 ),190 ),
91 get streamChanges() {191 get streamChanges() {
92 return Stream.fromPubSub(changesPubSub);192 return Stream.fromPubSub(changesPubSub);
apps/server/src/provider/makeManagedServerProvider.ts+2-1
...@@ -11,6 +11,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(...@@ -11,6 +11,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
11 readonly getSettings: Effect.Effect<Settings>;11 readonly getSettings: Effect.Effect<Settings>;
12 readonly streamSettings: Stream.Stream<Settings>;12 readonly streamSettings: Stream.Stream<Settings>;
13 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;13 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;
14 readonly initialSnapshot: (settings: Settings) => ServerProvider;
14 readonly checkProvider: Effect.Effect<ServerProvider, ServerSettingsError>;15 readonly checkProvider: Effect.Effect<ServerProvider, ServerSettingsError>;
15 readonly refreshInterval?: Duration.Input;16 readonly refreshInterval?: Duration.Input;
16}): Effect.fn.Return<ServerProviderShape, ServerSettingsError, Scope.Scope> {17}): Effect.fn.Return<ServerProviderShape, ServerSettingsError, Scope.Scope> {
...@@ -20,7 +21,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(...@@ -20,7 +21,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
20 PubSub.shutdown,21 PubSub.shutdown,
21 );22 );
22 const initialSettings = yield* input.getSettings;23 const initialSettings = yield* input.getSettings;
23 const initialSnapshot = yield* input.checkProvider;24 const initialSnapshot = input.initialSnapshot(initialSettings);
24 const snapshotRef = yield* Ref.make(initialSnapshot);25 const snapshotRef = yield* Ref.make(initialSnapshot);
25 const settingsRef = yield* Ref.make(initialSettings);26 const settingsRef = yield* Ref.make(initialSettings);
2627
apps/server/src/provider/providerStatusCache.test.ts created+136
...@@ -0,0 +1,136 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import type { ServerProvider } from "@t3tools/contracts";
3import { assert, it } from "@effect/vitest";
4import { Effect, FileSystem } from "effect";
5
6import {
7 hydrateCachedProvider,
8 readProviderStatusCache,
9 resolveProviderStatusCachePath,
10 writeProviderStatusCache,
11} from "./providerStatusCache";
12
13const makeProvider = (
14 provider: ServerProvider["provider"],
15 overrides?: Partial<ServerProvider>,
16): ServerProvider => ({
17 provider,
18 enabled: true,
19 installed: true,
20 version: "1.0.0",
21 status: "ready",
22 auth: { status: "authenticated" },
23 checkedAt: "2026-04-11T00:00:00.000Z",
24 models: [],
25 slashCommands: [],
26 skills: [],
27 ...overrides,
28});
29
30it.layer(NodeServices.layer)("providerStatusCache", (it) => {
31 it.effect("writes and reads provider status snapshots", () =>
32 Effect.gen(function* () {
33 const fs = yield* FileSystem.FileSystem;
34 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-" });
35 const codexProvider = makeProvider("codex");
36 const claudeProvider = makeProvider("claudeAgent", {
37 status: "warning",
38 auth: { status: "unknown" },
39 });
40 const codexPath = resolveProviderStatusCachePath({
41 cacheDir: tempDir,
42 provider: "codex",
43 });
44 const claudePath = resolveProviderStatusCachePath({
45 cacheDir: tempDir,
46 provider: "claudeAgent",
47 });
48
49 yield* writeProviderStatusCache({
50 filePath: codexPath,
51 provider: codexProvider,
52 });
53 yield* writeProviderStatusCache({
54 filePath: claudePath,
55 provider: claudeProvider,
56 });
57
58 assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider);
59 assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider);
60 }),
61 );
62
63 it("hydrates cached provider status onto current settings-derived models", () => {
64 const cachedCodex = makeProvider("codex", {
65 checkedAt: "2026-04-10T12:00:00.000Z",
66 models: [],
67 message: "Cached message",
68 skills: [
69 {
70 name: "github:gh-fix-ci",
71 path: "/tmp/skills/gh-fix-ci/SKILL.md",
72 enabled: true,
73 displayName: "CI Debug",
74 },
75 ],
76 });
77 const fallbackCodex = makeProvider("codex", {
78 models: [
79 {
80 slug: "gpt-5.4",
81 name: "GPT-5.4",
82 isCustom: false,
83 capabilities: {
84 reasoningEffortLevels: [],
85 supportsFastMode: false,
86 supportsThinkingToggle: false,
87 contextWindowOptions: [],
88 promptInjectedEffortLevels: [],
89 },
90 },
91 ],
92 message: "Pending refresh",
93 });
94
95 assert.deepStrictEqual(
96 hydrateCachedProvider({
97 cachedProvider: cachedCodex,
98 fallbackProvider: fallbackCodex,
99 }),
100 {
101 ...fallbackCodex,
102 installed: cachedCodex.installed,
103 version: cachedCodex.version,
104 status: cachedCodex.status,
105 auth: cachedCodex.auth,
106 checkedAt: cachedCodex.checkedAt,
107 slashCommands: cachedCodex.slashCommands,
108 skills: cachedCodex.skills,
109 message: cachedCodex.message,
110 },
111 );
112 });
113
114 it("ignores stale cached enabled state when the provider is now disabled", () => {
115 const cachedCodex = makeProvider("codex", {
116 checkedAt: "2026-04-10T12:00:00.000Z",
117 message: "Cached ready status",
118 });
119 const disabledFallback = makeProvider("codex", {
120 enabled: false,
121 installed: false,
122 version: null,
123 status: "disabled",
124 auth: { status: "unknown" },
125 message: "Codex is disabled in T3 Code settings.",
126 });
127
128 assert.deepStrictEqual(
129 hydrateCachedProvider({
130 cachedProvider: cachedCodex,
131 fallbackProvider: disabledFallback,
132 }),
133 disabledFallback,
134 );
135 });
136});
apps/server/src/provider/providerStatusCache.ts created+105
...@@ -0,0 +1,105 @@
1import * as nodePath from "node:path";
2import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts";
3import { Cause, Effect, FileSystem, Path, Schema } from "effect";
4
5export const PROVIDER_CACHE_IDS = ["codex", "claudeAgent"] as const satisfies ReadonlyArray<
6 ServerProvider["provider"]
7>;
8
9const decodeProviderStatusCache = Schema.decodeUnknownEffect(
10 Schema.fromJsonString(ServerProviderSchema),
11);
12
13const providerOrderRank = (provider: ServerProvider["provider"]): number => {
14 const rank = PROVIDER_CACHE_IDS.indexOf(provider);
15 return rank === -1 ? Number.MAX_SAFE_INTEGER : rank;
16};
17
18export const orderProviderSnapshots = (
19 providers: ReadonlyArray<ServerProvider>,
20): ReadonlyArray<ServerProvider> =>
21 [...providers].toSorted(
22 (left, right) => providerOrderRank(left.provider) - providerOrderRank(right.provider),
23 );
24
25export const hydrateCachedProvider = (input: {
26 readonly cachedProvider: ServerProvider;
27 readonly fallbackProvider: ServerProvider;
28}): ServerProvider => {
29 if (
30 !input.fallbackProvider.enabled ||
31 input.cachedProvider.enabled !== input.fallbackProvider.enabled
32 ) {
33 return input.fallbackProvider;
34 }
35
36 const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider;
37 const hydratedProvider: ServerProvider = {
38 ...fallbackWithoutMessage,
39 installed: input.cachedProvider.installed,
40 version: input.cachedProvider.version,
41 status: input.cachedProvider.status,
42 auth: input.cachedProvider.auth,
43 checkedAt: input.cachedProvider.checkedAt,
44 slashCommands: input.cachedProvider.slashCommands,
45 skills: input.cachedProvider.skills,
46 };
47
48 return input.cachedProvider.message
49 ? { ...hydratedProvider, message: input.cachedProvider.message }
50 : hydratedProvider;
51};
52
53export const resolveProviderStatusCachePath = (input: {
54 readonly cacheDir: string;
55 readonly provider: ServerProvider["provider"];
56}) => nodePath.join(input.cacheDir, `${input.provider}.json`);
57
58export const readProviderStatusCache = (filePath: string) =>
59 Effect.gen(function* () {
60 const fs = yield* FileSystem.FileSystem;
61 const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false));
62 if (!exists) {
63 return undefined;
64 }
65
66 const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => ""));
67 const trimmed = raw.trim();
68 if (trimmed.length === 0) {
69 return undefined;
70 }
71
72 return yield* decodeProviderStatusCache(trimmed).pipe(
73 Effect.matchCauseEffect({
74 onFailure: (cause) =>
75 Effect.logWarning("failed to parse provider status cache, ignoring", {
76 path: filePath,
77 issues: Cause.pretty(cause),
78 }).pipe(Effect.as(undefined)),
79 onSuccess: Effect.succeed,
80 }),
81 );
82 });
83
84export const writeProviderStatusCache = (input: {
85 readonly filePath: string;
86 readonly provider: ServerProvider;
87}) => {
88 const tempPath = `${input.filePath}.${process.pid}.${Date.now()}.tmp`;
89 return Effect.gen(function* () {
90 const fs = yield* FileSystem.FileSystem;
91 const path = yield* Path.Path;
92 const encoded = `${JSON.stringify(input.provider, null, 2)}\n`;
93
94 yield* fs.makeDirectory(path.dirname(input.filePath), { recursive: true });
95 yield* fs.writeFileString(tempPath, encoded);
96 yield* fs.rename(tempPath, input.filePath);
97 }).pipe(
98 Effect.ensuring(
99 Effect.gen(function* () {
100 const fs = yield* FileSystem.FileSystem;
101 yield* fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }));
102 }),
103 ),
104 );
105};
apps/server/src/server.test.ts+33-4
...@@ -1790,7 +1790,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1790,7 +1790,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
17901790
1791 it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>1791 it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>
1792 Effect.gen(function* () {1792 Effect.gen(function* () {
1793 const providers = [] as const;1793 const providers = [
1794 {
1795 provider: "codex" as const,
1796 enabled: true,
1797 installed: true,
1798 version: "1.0.0",
1799 status: "ready" as const,
1800 auth: { status: "authenticated" as const },
1801 checkedAt: "2026-04-11T00:00:00.000Z",
1802 models: [],
1803 slashCommands: [],
1804 skills: [],
1805 },
1806 ] as const;
1794 const changeEvent = {1807 const changeEvent = {
1795 keybindings: [],1808 keybindings: [],
1796 issues: [],1809 issues: [],
...@@ -1847,7 +1860,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1847,7 +1860,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
18471860
1848 it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>1861 it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>
1849 Effect.gen(function* () {1862 Effect.gen(function* () {
1850 const providers = [] as const;1863 const nextProviders = [
1864 {
1865 provider: "codex" as const,
1866 enabled: true,
1867 installed: true,
1868 version: "1.0.0",
1869 status: "ready" as const,
1870 auth: { status: "authenticated" as const },
1871 checkedAt: "2026-04-11T00:00:00.000Z",
1872 models: [],
1873 slashCommands: [],
1874 skills: [],
1875 },
1876 ] as const;
18511877
1852 yield* buildAppUnderTest({1878 yield* buildAppUnderTest({
1853 layers: {1879 layers: {
...@@ -1860,7 +1886,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1860,7 +1886,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1860 },1886 },
1861 providerRegistry: {1887 providerRegistry: {
1862 getProviders: Effect.succeed([]),1888 getProviders: Effect.succeed([]),
1863 streamChanges: Stream.succeed(providers),1889 streamChanges: Stream.succeed(nextProviders),
1864 },1890 },
1865 },1891 },
1866 });1892 });
...@@ -1874,10 +1900,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1874,10 +1900,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
18741900
1875 const [first, second] = Array.from(events);1901 const [first, second] = Array.from(events);
1876 assert.equal(first?.type, "snapshot");1902 assert.equal(first?.type, "snapshot");
1903 if (first?.type === "snapshot") {
1904 assert.deepEqual(first.config.providers, []);
1905 }
1877 assert.deepEqual(second, {1906 assert.deepEqual(second, {
1878 version: 1,1907 version: 1,
1879 type: "providerStatuses",1908 type: "providerStatuses",
1880 payload: { providers },1909 payload: { providers: nextProviders },
1881 });1910 });
1882 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1911 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1883 );1912 );
apps/server/src/serverRuntimeStartup.test.ts+117-3
...@@ -1,13 +1,21 @@...@@ -1,13 +1,21 @@
1import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
3import { Deferred, Effect, Fiber, Option, Ref } from "effect";4import { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect";
45
5import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";6import { ServerConfig } from "./config.ts";
7import {
8 OrchestrationEngineService,
9 type OrchestrationEngineShape,
10} from "./orchestration/Services/OrchestrationEngine.ts";
6import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";11import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
12import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
7import {13import {
8 getAutoBootstrapDefaultModelSelection,14 getAutoBootstrapDefaultModelSelection,
9 launchStartupHeartbeat,15 launchStartupHeartbeat,
10 makeCommandGate,16 makeCommandGate,
17 resolveAutoBootstrapWelcomeTargets,
18 resolveWelcomeBase,
11 ServerRuntimeStartupError,19 ServerRuntimeStartupError,
12} from "./serverRuntimeStartup.ts";20} from "./serverRuntimeStartup.ts";
1321
...@@ -93,3 +101,109 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa...@@ -93,3 +101,109 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa
93 }),101 }),
94 ),102 ),
95);103);
104
105it.effect("resolveWelcomeBase derives cwd and project name from server config", () =>
106 Effect.gen(function* () {
107 const welcome = yield* resolveWelcomeBase.pipe(
108 Effect.provideService(ServerConfig, {
109 cwd: "/tmp/startup-project",
110 } as never),
111 );
112
113 assert.deepStrictEqual(welcome, {
114 cwd: "/tmp/startup-project",
115 projectName: "startup-project",
116 });
117 }),
118);
119
120it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and thread ids", () => {
121 const bootstrapProjectId = ProjectId.make("project-startup-bootstrap");
122 const bootstrapThreadId = ThreadId.make("thread-startup-bootstrap");
123
124 return Effect.gen(function* () {
125 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);
126 const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe(
127 Effect.provideService(ServerConfig, {
128 cwd: "/tmp/startup-project",
129 autoBootstrapProjectFromCwd: true,
130 } as never),
131 Effect.provideService(ProjectionSnapshotQuery, {
132 getSnapshot: () => Effect.die("unused"),
133 getShellSnapshot: () => Effect.die("unused"),
134 getCounts: () => Effect.die("unused"),
135 getActiveProjectByWorkspaceRoot: () =>
136 Effect.succeed(
137 Option.some({
138 id: bootstrapProjectId,
139 title: "Startup Project",
140 workspaceRoot: "/tmp/startup-project",
141 defaultModelSelection: getAutoBootstrapDefaultModelSelection(),
142 scripts: [],
143 createdAt: "2026-01-01T00:00:00.000Z",
144 updatedAt: "2026-01-01T00:00:00.000Z",
145 deletedAt: null,
146 }),
147 ),
148 getProjectShellById: () => Effect.die("unused"),
149 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)),
150 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
151 getThreadShellById: () => Effect.die("unused"),
152 getThreadDetailById: () => Effect.die("unused"),
153 }),
154 Effect.provideService(OrchestrationEngineService, {
155 getReadModel: () => Effect.die("unused"),
156 readEvents: () => Stream.empty,
157 dispatch: (command) =>
158 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
159 Effect.as({ sequence: 1 }),
160 ),
161 streamDomainEvents: Stream.empty,
162 } satisfies OrchestrationEngineShape),
163 Effect.provide(NodeServices.layer),
164 );
165
166 assert.deepStrictEqual(targets, {
167 bootstrapProjectId,
168 bootstrapThreadId,
169 });
170 assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []);
171 });
172});
173
174it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () =>
175 Effect.gen(function* () {
176 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);
177 const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe(
178 Effect.provideService(ServerConfig, {
179 cwd: "/tmp/startup-project",
180 autoBootstrapProjectFromCwd: true,
181 } as never),
182 Effect.provideService(ProjectionSnapshotQuery, {
183 getSnapshot: () => Effect.die("unused"),
184 getShellSnapshot: () => Effect.die("unused"),
185 getCounts: () => Effect.die("unused"),
186 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
187 getProjectShellById: () => Effect.die("unused"),
188 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
189 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
190 getThreadShellById: () => Effect.die("unused"),
191 getThreadDetailById: () => Effect.die("unused"),
192 }),
193 Effect.provideService(OrchestrationEngineService, {
194 getReadModel: () => Effect.die("unused"),
195 readEvents: () => Stream.empty,
196 dispatch: (command) =>
197 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
198 Effect.as({ sequence: 1 }),
199 ),
200 streamDomainEvents: Stream.empty,
201 } satisfies OrchestrationEngineShape),
202 Effect.provide(NodeServices.layer),
203 );
204
205 assert.equal(typeof targets.bootstrapProjectId, "string");
206 assert.equal(typeof targets.bootstrapThreadId, "string");
207 assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create", "thread.create"]);
208 }),
209);
apps/server/src/serverRuntimeStartup.ts+55-14
...@@ -157,7 +157,18 @@ export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({...@@ -157,7 +157,18 @@ export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({
157 model: DEFAULT_MODEL_BY_PROVIDER.codex,157 model: DEFAULT_MODEL_BY_PROVIDER.codex,
158});158});
159159
160const autoBootstrapWelcome = Effect.gen(function* () {160export const resolveWelcomeBase = Effect.gen(function* () {
161 const serverConfig = yield* ServerConfig;
162 const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean);
163 const projectName = segments[segments.length - 1] ?? "project";
164
165 return {
166 cwd: serverConfig.cwd,
167 projectName,
168 } as const;
169});
170
171export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
161 const serverConfig = yield* ServerConfig;172 const serverConfig = yield* ServerConfig;
162 const projectionReadModelQuery = yield* ProjectionSnapshotQuery;173 const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
163 const orchestrationEngine = yield* OrchestrationEngineService;174 const orchestrationEngine = yield* OrchestrationEngineService;
...@@ -221,12 +232,7 @@ const autoBootstrapWelcome = Effect.gen(function* () {...@@ -221,12 +232,7 @@ const autoBootstrapWelcome = Effect.gen(function* () {
221 });232 });
222 }233 }
223234
224 const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean);
225 const projectName = segments[segments.length - 1] ?? "project";
226
227 return {235 return {
228 cwd: serverConfig.cwd,
229 projectName,
230 ...(bootstrapProjectId ? { bootstrapProjectId } : {}),236 ...(bootstrapProjectId ? { bootstrapProjectId } : {}),
231 ...(bootstrapThreadId ? { bootstrapThreadId } : {}),237 ...(bootstrapThreadId ? { bootstrapThreadId } : {}),
232 } as const;238 } as const;
...@@ -271,7 +277,7 @@ const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>)...@@ -271,7 +277,7 @@ const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>)
271 Effect.withSpan(`server.startup.${phase}`),277 Effect.withSpan(`server.startup.${phase}`),
272 );278 );
273279
274const makeServerRuntimeStartup = Effect.gen(function* () {280export const makeServerRuntimeStartup = Effect.gen(function* () {
275 const serverConfig = yield* ServerConfig;281 const serverConfig = yield* ServerConfig;
276 const keybindings = yield* Keybindings;282 const keybindings = yield* Keybindings;
277 const orchestrationReactor = yield* OrchestrationReactor;283 const orchestrationReactor = yield* OrchestrationReactor;
...@@ -322,15 +328,13 @@ const makeServerRuntimeStartup = Effect.gen(function* () {...@@ -322,15 +328,13 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
322 orchestrationReactor.start().pipe(Scope.provide(reactorScope)),328 orchestrationReactor.start().pipe(Scope.provide(reactorScope)),
323 );329 );
324330
325 yield* Effect.logDebug("startup phase: preparing welcome payload");331 const welcomeBase = yield* resolveWelcomeBase;
326 const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome);
327 const environment = yield* serverEnvironment.getDescriptor;332 const environment = yield* serverEnvironment.getDescriptor;
333 yield* Effect.logDebug("startup phase: preparing welcome payload");
328 yield* Effect.logDebug("startup phase: publishing welcome event", {334 yield* Effect.logDebug("startup phase: publishing welcome event", {
329 environmentId: environment.environmentId,335 environmentId: environment.environmentId,
330 cwd: welcome.cwd,336 cwd: welcomeBase.cwd,
331 projectName: welcome.projectName,337 projectName: welcomeBase.projectName,
332 bootstrapProjectId: welcome.bootstrapProjectId,
333 bootstrapThreadId: welcome.bootstrapThreadId,
334 });338 });
335 yield* runStartupPhase(339 yield* runStartupPhase(
336 "welcome.publish",340 "welcome.publish",
...@@ -339,10 +343,47 @@ const makeServerRuntimeStartup = Effect.gen(function* () {...@@ -339,10 +343,47 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
339 type: "welcome",343 type: "welcome",
340 payload: {344 payload: {
341 environment,345 environment,
342 ...welcome,346 ...welcomeBase,
343 },347 },
344 }),348 }),
345 );349 );
350
351 if (serverConfig.autoBootstrapProjectFromCwd) {
352 yield* Effect.forkScoped(
353 runStartupPhase(
354 "welcome.autobootstrap",
355 Effect.gen(function* () {
356 const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets;
357 if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) {
358 return;
359 }
360
361 yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", {
362 environmentId: environment.environmentId,
363 cwd: welcomeBase.cwd,
364 projectName: welcomeBase.projectName,
365 bootstrapProjectId: bootstrapTargets.bootstrapProjectId,
366 bootstrapThreadId: bootstrapTargets.bootstrapThreadId,
367 });
368 yield* lifecycleEvents.publish({
369 version: 1,
370 type: "welcome",
371 payload: {
372 environment,
373 ...welcomeBase,
374 ...bootstrapTargets,
375 },
376 });
377 }).pipe(
378 Effect.catch((cause) =>
379 Effect.logWarning("startup auto-bootstrap welcome failed", {
380 cause,
381 }),
382 ),
383 ),
384 ),
385 );
386 }
346 }).pipe(387 }).pipe(
347 Effect.annotateSpans({388 Effect.annotateSpans({
348 "server.mode": serverConfig.mode,389 "server.mode": serverConfig.mode,
apps/server/src/ws.ts+18-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect";1import { Cause, Duration, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect";
2import {2import {
3 type AuthAccessStreamEvent,3 type AuthAccessStreamEvent,
4 AuthSessionId,4 AuthSessionId,
...@@ -85,6 +85,8 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract<...@@ -85,6 +85,8 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract<
85 );85 );
86}86}
8787
88const PROVIDER_STATUS_DEBOUNCE_MS = 200;
89
88function toAuthAccessStreamEvent(90function toAuthAccessStreamEvent(
89 change: BootstrapCredentialChange | SessionCredentialChange,91 change: BootstrapCredentialChange | SessionCredentialChange,
90 revision: number,92 revision: number,
...@@ -919,6 +921,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -919,6 +921,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
919 type: "providerStatuses" as const,921 type: "providerStatuses" as const,
920 payload: { providers },922 payload: { providers },
921 })),923 })),
924 Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)),
922 );925 );
923 const settingsUpdates = serverSettings.streamChanges.pipe(926 const settingsUpdates = serverSettings.streamChanges.pipe(
924 Stream.map((settings) => ({927 Stream.map((settings) => ({
...@@ -928,13 +931,26 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -928,13 +931,26 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
928 })),931 })),
929 );932 );
930933
934 yield* Effect.all(
935 [providerRegistry.refresh("codex"), providerRegistry.refresh("claudeAgent")],
936 {
937 concurrency: "unbounded",
938 discard: true,
939 },
940 ).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped);
941
942 const liveUpdates = Stream.merge(
943 keybindingsUpdates,
944 Stream.merge(providerStatuses, settingsUpdates),
945 );
946
931 return Stream.concat(947 return Stream.concat(
932 Stream.make({948 Stream.make({
933 version: 1 as const,949 version: 1 as const,
934 type: "snapshot" as const,950 type: "snapshot" as const,
935 config: yield* loadServerConfig,951 config: yield* loadServerConfig,
936 }),952 }),
937 Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)),953 liveUpdates,
938 );954 );
939 }),955 }),
940 { "rpc.aggregate": "server" },956 { "rpc.aggregate": "server" },