| author | |
| committer | |
| log | 008ac5c3e5c81d297bdb7fc70bd04b7f69ca36c6 |
| tree | 530097e86fb63763f2484156fb9159138d3c3231 |
| parent | 9dcea68bce25e1c30be7a6439bfe607b236740a0 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 8 | 8 | ||
| 9 | describe("waitForHttpReady", () => { | 9 | describe("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 = vi | 11 | 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 | }); |
| 21 | 21 | ||
| 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 | }); |
| 24 | 29 | ||
| 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 | } |
| 8 | 10 | ||
| 9 | const DEFAULT_TIMEOUT_MS = 30_000; | 11 | const 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; |
| 61 | 65 | ||
| 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 }); |
| 75 | 79 | ||
| 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"; |
| 55 | import { resolveDesktopServerExposure } from "./serverExposure"; | 55 | import { resolveDesktopServerExposure } from "./serverExposure"; |
| 56 | import { syncShellEnvironment } from "./syncShellEnvironment"; | 56 | import { syncShellEnvironment } from "./syncShellEnvironment"; |
| 57 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; | 57 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; |
| 58 | import { ServerListeningDetector } from "./serverListeningDetector"; | ||
| 58 | import { | 59 | import { |
| 59 | createInitialDesktopUpdateState, | 60 | createInitialDesktopUpdateState, |
| 60 | reduceDesktopUpdateStateOnCheckFailure, | 61 | reduceDesktopUpdateStateOnCheckFailure, |
| ... | @@ -144,6 +145,8 @@ let backendWsUrl = ""; | ... | @@ -144,6 +145,8 @@ let backendWsUrl = ""; |
| 144 | let backendEndpointUrl: string | null = null; | 145 | let backendEndpointUrl: string | null = null; |
| 145 | let backendAdvertisedHost: string | null = null; | 146 | let backendAdvertisedHost: string | null = null; |
| 146 | let backendReadinessAbortController: AbortController | null = null; | 147 | let backendReadinessAbortController: AbortController | null = null; |
| 148 | let backendInitialWindowOpenInFlight: Promise<void> | null = null; | ||
| 149 | let backendListeningDetector: ServerListeningDetector | null = null; | ||
| 147 | let restartAttempt = 0; | 150 | let restartAttempt = 0; |
| 148 | let restartTimer: ReturnType<typeof setTimeout> | null = null; | 151 | let restartTimer: ReturnType<typeof setTimeout> | null = null; |
| 149 | let isQuitting = false; | 152 | let 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 | } |
| 364 | 367 | ||
| 365 | async function waitForBackendHttpReady(baseUrl: string): Promise<void> { | 368 | async 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; |
| 369 | 375 | ||
| 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 | } |
| 385 | 392 | ||
| 393 | async 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 | |||
| 442 | function 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 | |||
| 386 | function writeDesktopStreamChunk( | 475 | function 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 | } |
| 461 | 550 | ||
| 462 | function captureBackendOutput(child: ChildProcess.ChildProcess): void { | 551 | function 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 | } |
| 472 | 563 | ||
| 473 | initializePackagedLogging(); | 564 | initializePackagedLogging(); |
| ... | @@ -1222,7 +1313,7 @@ function startBackend(): void { | ... | @@ -1222,7 +1313,7 @@ function startBackend(): void { |
| 1222 | return; | 1313 | return; |
| 1223 | } | 1314 | } |
| 1224 | 1315 | ||
| 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 | }); |
| 1278 | 1371 | ||
| 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 | }); |
| 1290 | 1387 | ||
| 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 | } |
| 1304 | 1411 | ||
| 1305 | function stopBackend(): void { | 1412 | function 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); | ||
| 1787 | 1901 | ||
| 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 | } |
| 1797 | 1908 | ||
| 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 | } |
| 1806 | 1917 | ||
| 1807 | function 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 that | 1918 | // 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 | } |
| 1887 | 1990 | ||
| 1888 | await waitForBackendHttpReady(backendHttpUrl); | 1991 | ensureInitialBackendWindowOpen(); |
| 1889 | writeDesktopLogHeader("bootstrap backend ready"); | ||
| 1890 | mainWindow = createWindow(); | ||
| 1891 | writeDesktopLogHeader("bootstrap main window created"); | ||
| 1892 | } | 1992 | } |
| 1893 | 1993 | ||
| 1894 | app.on("before-quit", () => { | 1994 | app.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 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { ServerListeningDetector } from "./serverListeningDetector"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | const LISTENING_LOG_FRAGMENT = "Listening on http://"; | ||
| 2 | const MAX_BUFFER_CHARS = 8_192; | ||
| 3 | |||
| 4 | export 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 | }); |
| 665 | 665 | ||
| 666 | const 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 | |||
| 666 | export const ClaudeProviderLive = Layer.effect( | 706 | export 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 | }); |
| 554 | 554 | ||
| 555 | const 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 | |||
| 555 | export const CodexProviderLive = Layer.effect( | 595 | export 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"; |
| 32 | import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; | 32 | import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; |
| 33 | import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; | 33 | import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; |
| 34 | import { ServerConfig } from "../../config"; | ||
| 34 | import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; | 35 | import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; |
| 35 | import { ProviderRegistry } from "../Services/ProviderRegistry"; | 36 | import { ProviderRegistry } from "../Services/ProviderRegistry"; |
| 36 | 37 | ||
| ... | @@ -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 | }); |
| 564 | 565 | ||
| 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; |
| 597 | 658 | ||
| 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 | ); |
| 603 | 667 |
apps/server/src/provider/Layers/ProviderRegistry.ts+131-31| ... | @@ -4,8 +4,9 @@ | ... | @@ -4,8 +4,9 @@ |
| 4 | * @module ProviderRegistryLive | 4 | * @module ProviderRegistryLive |
| 5 | */ | 5 | */ |
| 6 | import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; | 6 | import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; |
| 7 | import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; | 7 | import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; |
| 8 | 8 | ||
| 9 | import { ServerConfig } from "../../config"; | ||
| 9 | import { ClaudeProviderLive } from "./ClaudeProvider"; | 10 | import { ClaudeProviderLive } from "./ClaudeProvider"; |
| 10 | import { CodexProviderLive } from "./CodexProvider"; | 11 | import { CodexProviderLive } from "./CodexProvider"; |
| 11 | import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; | 12 | import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; |
| ... | @@ -13,6 +14,14 @@ import { ClaudeProvider } from "../Services/ClaudeProvider"; | ... | @@ -13,6 +14,14 @@ import { ClaudeProvider } from "../Services/ClaudeProvider"; |
| 13 | import type { CodexProviderShape } from "../Services/CodexProvider"; | 14 | import type { CodexProviderShape } from "../Services/CodexProvider"; |
| 14 | import { CodexProvider } from "../Services/CodexProvider"; | 15 | import { CodexProvider } from "../Services/CodexProvider"; |
| 15 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; | 16 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; |
| 17 | import { | ||
| 18 | hydrateCachedProvider, | ||
| 19 | PROVIDER_CACHE_IDS, | ||
| 20 | orderProviderSnapshots, | ||
| 21 | readProviderStatusCache, | ||
| 22 | resolveProviderStatusCachePath, | ||
| 23 | writeProviderStatusCache, | ||
| 24 | } from "../providerStatusCache"; | ||
| 16 | 25 | ||
| 17 | const loadProviders = ( | 26 | const 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 | } | ||
| 42 | 121 | ||
| 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); | ||
| 49 | 126 | ||
| 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 | } |
| 53 | 136 | ||
| 54 | return providers; | 137 | return providers; |
| 55 | }); | 138 | }); |
| 56 | 139 | ||
| 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 | }); | ||
| 63 | 148 | ||
| 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 | }); |
| 80 | 176 | ||
| 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); |
| 26 | 27 |
apps/server/src/provider/providerStatusCache.test.ts created+136| ... | @@ -0,0 +1,136 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import type { ServerProvider } from "@t3tools/contracts"; | ||
| 3 | import { assert, it } from "@effect/vitest"; | ||
| 4 | import { Effect, FileSystem } from "effect"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | hydrateCachedProvider, | ||
| 8 | readProviderStatusCache, | ||
| 9 | resolveProviderStatusCachePath, | ||
| 10 | writeProviderStatusCache, | ||
| 11 | } from "./providerStatusCache"; | ||
| 12 | |||
| 13 | const 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 | |||
| 30 | it.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 @@ | ||
| 1 | import * as nodePath from "node:path"; | ||
| 2 | import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; | ||
| 3 | import { Cause, Effect, FileSystem, Path, Schema } from "effect"; | ||
| 4 | |||
| 5 | export const PROVIDER_CACHE_IDS = ["codex", "claudeAgent"] as const satisfies ReadonlyArray< | ||
| 6 | ServerProvider["provider"] | ||
| 7 | >; | ||
| 8 | |||
| 9 | const decodeProviderStatusCache = Schema.decodeUnknownEffect( | ||
| 10 | Schema.fromJsonString(ServerProviderSchema), | ||
| 11 | ); | ||
| 12 | |||
| 13 | const providerOrderRank = (provider: ServerProvider["provider"]): number => { | ||
| 14 | const rank = PROVIDER_CACHE_IDS.indexOf(provider); | ||
| 15 | return rank === -1 ? Number.MAX_SAFE_INTEGER : rank; | ||
| 16 | }; | ||
| 17 | |||
| 18 | export const orderProviderSnapshots = ( | ||
| 19 | providers: ReadonlyArray<ServerProvider>, | ||
| 20 | ): ReadonlyArray<ServerProvider> => | ||
| 21 | [...providers].toSorted( | ||
| 22 | (left, right) => providerOrderRank(left.provider) - providerOrderRank(right.provider), | ||
| 23 | ); | ||
| 24 | |||
| 25 | export 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 | |||
| 53 | export const resolveProviderStatusCachePath = (input: { | ||
| 54 | readonly cacheDir: string; | ||
| 55 | readonly provider: ServerProvider["provider"]; | ||
| 56 | }) => nodePath.join(input.cacheDir, `${input.provider}.json`); | ||
| 57 | |||
| 58 | export 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 | |||
| 84 | export 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) => { |
| 1790 | 1790 | ||
| 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) => { |
| 1847 | 1860 | ||
| 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; | ||
| 1851 | 1877 | ||
| 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) => { |
| 1874 | 1900 | ||
| 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 @@ |
| 1 | import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | 3 | import { assert, it } from "@effect/vitest"; |
| 3 | import { Deferred, Effect, Fiber, Option, Ref } from "effect"; | 4 | import { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect"; |
| 4 | 5 | ||
| 5 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; | 6 | import { ServerConfig } from "./config.ts"; |
| 7 | import { | ||
| 8 | OrchestrationEngineService, | ||
| 9 | type OrchestrationEngineShape, | ||
| 10 | } from "./orchestration/Services/OrchestrationEngine.ts"; | ||
| 6 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 11 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 12 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; | ||
| 7 | import { | 13 | import { |
| 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"; |
| 13 | 21 | ||
| ... | @@ -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 | |||
| 105 | it.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 | |||
| 120 | it.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 | |||
| 174 | it.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 | }); |
| 159 | 159 | ||
| 160 | const autoBootstrapWelcome = Effect.gen(function* () { | 160 | export 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 | |||
| 171 | export 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 | } |
| 223 | 234 | ||
| 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 | ); |
| 273 | 279 | ||
| 274 | const makeServerRuntimeStartup = Effect.gen(function* () { | 280 | export 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 | ); |
| 324 | 330 | ||
| 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 @@ |
| 1 | import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; | 1 | import { Cause, Duration, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; |
| 2 | import { | 2 | import { |
| 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 | } |
| 87 | 87 | ||
| 88 | const PROVIDER_STATUS_DEBOUNCE_MS = 200; | ||
| 89 | |||
| 88 | function toAuthAccessStreamEvent( | 90 | function 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 | ); |
| 930 | 933 | ||
| 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" }, |