diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 91b17666e8985..8b0707d5d007d 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -24,7 +24,6 @@ import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; -import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -35,7 +34,6 @@ import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; @@ -166,6 +164,13 @@ const bootstrap = Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; const snapShot = yield* DesktopSnapShot.DesktopSnapShot; const appActivation = yield* DesktopAppActivation.DesktopAppActivation; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + const shutdown = yield* DesktopShutdown.DesktopShutdown; + // A quit during shell startup must prevent subsequent bootstrap work. + const awaitShellEnvironment = Effect.raceFirst( + shellEnvironment.installIntoProcess.pipe(Effect.as(true)), + shutdown.awaitRequest.pipe(Effect.as(false)), + ); yield* logBootstrapInfo("bootstrap start"); const settings = yield* desktopSettings.get; @@ -184,13 +189,14 @@ const bootstrap = Effect.gen(function* () { yield* (yield* DesktopAppIdentity.DesktopAppIdentity).resolveUserDataPath, ); - yield* snapShot.initialize; - if (!settings.localEnvironmentEnabled) { yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); if (!(yield* Ref.get(state.quitting))) { yield* desktopWindow.createMainIfBackendReady; } + // Snap Shot reads the desktop session variables the login shell provides. + if (!(yield* awaitShellEnvironment)) return; + yield* snapShot.initialize; return; } @@ -239,17 +245,22 @@ const bootstrap = Effect.gen(function* () { } if (!(yield* Ref.get(state.quitting))) { - if (settings.wslOnly === true && settings.wslBackendEnabled === true) { + const wslOnly = settings.wslOnly === true && settings.wslBackendEnabled === true; + // The main window waits for the primary backend. In wsl-only mode that is + // the WSL backend, which can be slow to cold-boot — show a "Connecting to + // WSL" splash immediately so the app feels responsive instead of presenting + // no window until WSL is ready. (Dual mode opens fast off the Windows + // primary, so no splash there.) + if (wslOnly) { yield* desktopWindow.showConnectingSplash; + // Resolving the WSL primary's config runs wsl.exe. + if (!(yield* awaitShellEnvironment)) return; } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); - // The renderer loads bundled assets and can connect while the backend - // boots. Starting first makes its endpoint and bootstrap credential - // available before the preload reads them. WSL-only retains its splash - // until the primary has a usable endpoint. A failed open is not fatal: - // backend readiness tries again. - if (!(settings.wslOnly === true && settings.wslBackendEnabled === true)) { + // Publish the endpoint and token before preload reads them. Backend readiness + // retries a failed window open; WSL keeps its splash until then. + if (!wslOnly) { yield* desktopWindow.ensureMain.pipe( Effect.catch((error) => logBootstrapWarning("failed to open main window while the backend starts", { @@ -258,6 +269,9 @@ const bootstrap = Effect.gen(function* () { ), ); } + // Snap Shot reads the desktop session variables the login shell provides. + if (!(yield* awaitShellEnvironment)) return; + yield* snapShot.initialize; yield* appActivation.start.pipe( Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), @@ -275,50 +289,19 @@ const startup = Effect.gen(function* () { const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; - const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; + const state = yield* DesktopState.DesktopState; - yield* shellEnvironment.installIntoProcess; - const hasCommandLinePasswordStore = - preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; - const linuxElectronOptions = - environment.platform === "linux" && !hasCommandLinePasswordStore - ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess( - yield* HostProcess.HomeDirectory, - ) - : preReadyElectronOptions.linux; - if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) { - if ( - linuxElectronOptions.passwordStore !== null || - preReadyElectronOptions.linux?.passwordStore !== null - ) { - yield* electronApp.removeCommandLineSwitch("password-store"); - } - if (linuxElectronOptions.passwordStore !== null) { - yield* electronApp.appendCommandLineSwitch( - "password-store", - linuxElectronOptions.passwordStore, - ); - } - } + // Open the window while the shell loads; spawning work awaits the same run. + yield* Effect.forkScoped(shellEnvironment.installIntoProcess); const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); yield* desktopSettings.load; - if (linuxElectronOptions !== null) { - yield* logStartupInfo("linux password store configured", { - passwordStore: hasCommandLinePasswordStore - ? "command-line" - : (linuxElectronOptions.passwordStore ?? "electron-default"), - xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null, - xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null, - }); - } - yield* appIdentity.configure; yield* lifecycle.register; yield* clerk.configure; @@ -329,18 +312,25 @@ const startup = Effect.gen(function* () { ); yield* logStartupInfo("app ready"); if (environment.platform === "linux") { - const selectedBackend = yield* safeStorage.selectedStorageBackend; - yield* logStartupInfo("safe storage ready", { - backend: Option.getOrElse(selectedBackend, () => "unknown"), - }); + yield* Effect.forkScoped( + shellEnvironment.secretStorageReady.pipe( + Effect.andThen(safeStorage.selectedStorageBackend), + Effect.flatMap((selectedBackend) => + logStartupInfo("safe storage ready", { + backend: Option.getOrElse(selectedBackend, () => "unknown"), + }), + ), + ), + ); } yield* appIdentity.configure; yield* previewPasskeys.configure; yield* applicationMenu.configure; + yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); + if (yield* Ref.get(state.quitting)) return; yield* updates.configure; yield* DesktopRemoteUpdates.listen; yield* linuxUrlHandler.register; - yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); const scopedProgram = Effect.scoped( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index c0ef57ae993ac..17c24f5f007a8 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -41,6 +41,7 @@ const isDesktopBackendObservabilitySettingsReadError = Schema.is( const layerShellEnvironment = (prepared = true) => Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(prepared), + secretStorageReady: Effect.void, }); const layerBackendConfiguration = (prepared = true) => @@ -124,6 +125,7 @@ const withHarness = ( | DesktopBackendConfiguration.DesktopBackendConfiguration >, prepared = true, + platform: NodeJS.Platform = "darwin", ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -138,7 +140,7 @@ const withHarness = ( Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(layerEnvironment(baseDir)), + Layer.provideMerge(layerEnvironment(baseDir, { platform })), ), ), ); @@ -347,7 +349,7 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("keeps server hydration enabled when desktop preparation failed", () => + it.effect("keeps server hydration enabled when Windows preparation failed", () => withHarness( Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -355,6 +357,7 @@ describe("DesktopBackendConfiguration", () => { assert.equal(primary.bootstrap.shellEnvironmentPrepared, false); }), false, + "win32", ), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index d9ff98279718f..3230ac4f8b7b5 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -820,7 +820,12 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); - const shellEnvironmentPrepared = yield* shellEnvironment.installIntoProcess; + // POSIX installation always returns true and runs at the spawn gate. Keep + // Windows probes here so a failure still enables server-side hydration. + const shellEnvironmentPrepared = + environment.platform === "darwin" || + environment.platform === "linux" || + (yield* shellEnvironment.installIntoProcess); return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath, diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 15ed2abe7517f..546833b7fad45 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -141,6 +141,7 @@ interface MakeInstanceInput { DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"] >; readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; + readonly beforeSpawn?: Effect.Effect; } function makeTestInstance(input: MakeInstanceInput) { @@ -187,6 +188,7 @@ function makeTestInstance(input: MakeInstanceInput) { ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), ...(input.onStateDirOwned ? { onStateDirOwned: () => input.onStateDirOwned! } : {}), ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), + ...(input.beforeSpawn ? { beforeSpawn: input.beforeSpawn } : {}), }); return instance.pipe(Effect.provide(layerServices)); @@ -935,6 +937,42 @@ describe("DesktopBackendManager", () => { ), ); + it.effect("publishes its config before the spawn gate and never spawns once stopped", () => + Effect.scoped( + Effect.gen(function* () { + const spawned = yield* Queue.unbounded(); + const gate = yield* Deferred.make(); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Queue.offer(spawned, undefined).pipe( + Effect.as(makeProcess({ exitCode: Effect.never })), + ), + ), + ); + const instance = yield* makeTestInstance({ + spawnerLayer: layerSpawner, + beforeSpawn: Deferred.await(gate), + }); + + // The endpoint and token are readable while the spawn waits. + yield* instance.start; + assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); + assert.equal(yield* Queue.size(spawned), 0); + + yield* instance.stop().pipe(Effect.timeout("1 second")); + yield* Deferred.succeed(gate, undefined); + yield* Effect.yieldNow; + assert.equal(yield* Queue.size(spawned), 0); + assert.isTrue(Option.isNone((yield* instance.snapshot).activePid)); + + // A later start spawns as soon as the gate is open. + yield* instance.start; + yield* Queue.take(spawned).pipe(Effect.timeout("1 second")); + }), + ), + ); + it.effect("restarts when start is requested during stop teardown", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 8d2a3f1c4912f..82d467d0216cf 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1,5 +1,6 @@ import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -41,6 +42,10 @@ const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); // and a busy one needs a few seconds to stop every provider session. const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); +const STOPPED_BEFORE_SPAWN: BackendProcessExit = { + code: Option.none(), + reason: "stopped before spawn", +}; const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; const { logWarning: logBackendProcessWarning } = DesktopObservability.makeComponentLogger("desktop-backend-process"); @@ -241,6 +246,11 @@ export interface BackendInstanceSpec { // between "fired onReady" and "currentConfig already advanced". readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; + // Wait after publishing config so preload can read credentials before spawn. + readonly beforeSpawn?: Effect.Effect; + // Fired once when a fatal or bounded preflight failure has exhausted its + // retries. Returns true when the callback changed configuration and the + // manager should resolve once more; false stops the failed instance. readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; readonly onStateDirOwned?: () => Effect.Effect; } @@ -657,6 +667,20 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); }); + // Stop must release the spawn gate without starting the stopped run. + const awaitBeforeSpawn = (runScope: Scope.Scope): Effect.Effect => { + const beforeSpawn = spec.beforeSpawn; + if (beforeSpawn === undefined) return Effect.succeed(true); + return Effect.gen(function* () { + const stopped = yield* Deferred.make(); + yield* Scope.addFinalizer(runScope, Deferred.succeed(stopped, undefined)); + return yield* Effect.raceFirst( + beforeSpawn.pipe(Effect.as(true)), + Deferred.await(stopped).pipe(Effect.as(false)), + ); + }); + }; + const start: Effect.Effect = Effect.suspend(() => mutex.withPermits(1)( Effect.gen(function* () { @@ -868,7 +892,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ); }); - const program = runBackendProcess({ + const spawnProcess = runBackendProcess({ ...config.value, desktopTelemetryStream: desktopTelemetryPublisher.encoded, desktopBrowserStream: desktopBrowserHost.events, @@ -931,7 +955,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }, ), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), - }).pipe( + }); + const program = awaitBeforeSpawn(runScope).pipe( + Effect.flatMap((spawn) => (spawn ? spawnProcess : Effect.succeed(STOPPED_BEFORE_SPAWN))), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), Scope.provide(runScope), diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 72a05e9d657d1..ab8471db6c294 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; @@ -85,6 +86,10 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer(); @@ -176,6 +181,7 @@ export const layer = Layer.effect( ), onShutdown: () => desktopWindow.handleBackendNotReady, onPreflightFailed: handlePrimaryPreflightFailure, + beforeSpawn: shellEnvironment.installIntoProcess.pipe(Effect.asVoid), onStateDirOwned: () => electronDialog .showErrorBox( diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index caed223e90e86..fc504ba81f202 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { @@ -83,7 +84,31 @@ import { } from "./methods/cliCommand.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { - const ipc = yield* DesktopIpc.DesktopIpc; + const desktopIpc = yield* DesktopIpc.DesktopIpc; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + // Process-spawning handlers need the login environment before they run. + const ipc = { + handle: (method: DesktopIpc.DesktopIpcMethod) => + desktopIpc.handle({ + channel: method.channel, + handler: (raw, event) => + shellEnvironment.installIntoProcess.pipe( + Effect.andThen(() => method.handler(raw, event)), + ), + }), + // For handlers that spawn nothing and read no login shell variables. + handleWithoutShell: desktopIpc.handle, + // For handlers that use safeStorage, which can need the shell's session bus. + handleSecretStorage: (method: DesktopIpc.DesktopIpcMethod) => + desktopIpc.handle({ + channel: method.channel, + handler: (raw, event) => + shellEnvironment.secretStorageReady.pipe( + Effect.andThen(() => method.handler(raw, event)), + ), + }), + handleSync: desktopIpc.handleSync, + }; yield* installNotificationBadge(); yield* PreviewIpc.installPreviewEventForwarding(); @@ -99,11 +124,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); - yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handleWithoutShell(getLocalEnvironmentBearerToken); - yield* ipc.handle(getClientSettings); - yield* ipc.handle(setClientSettings); - yield* ipc.handle(getConnectionCatalog); + yield* ipc.handleWithoutShell(getClientSettings); + yield* ipc.handleWithoutShell(setClientSettings); + yield* ipc.handleSecretStorage(getConnectionCatalog); yield* ipc.handle(getSnapShotState); yield* ipc.handle(setupSnapShot); yield* ipc.handle(previewSnapShotConfig); @@ -116,8 +141,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setSnapShotAnimationDestination); yield* ipc.handle(dismissSnapShotAnimation); yield* ipc.handle(acknowledgeSnapShot); - yield* ipc.handle(setConnectionCatalog); - yield* ipc.handle(clearConnectionCatalog); + yield* ipc.handleSecretStorage(setConnectionCatalog); + yield* ipc.handleSecretStorage(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); yield* ipc.handle(resolveSshHost); @@ -142,8 +167,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pickFolder); yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); - yield* ipc.handle(setTheme); - yield* ipc.handle(showContextMenu); + yield* ipc.handleWithoutShell(setTheme); + yield* ipc.handleWithoutShell(showContextMenu); yield* ipc.handle(openExternal); yield* ipc.handle(receiveProviderAuthCallback); yield* ipc.handle(cancelProviderAuthCallback); diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts index 7a647a58dc431..f160574a32a01 100644 --- a/apps/desktop/src/ipc/methods/clientSettings.ts +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -4,6 +4,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; +import * as DesktopShellEnvironment from "../../shell/DesktopShellEnvironment.ts"; import * as DesktopSnapShot from "../../snapShot/DesktopSnapShot.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; @@ -25,7 +26,10 @@ export const setClientSettings = DesktopIpc.makeIpcMethod({ handler: Effect.fn("desktop.ipc.clientSettings.set")(function* (settings) { const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const snapShot = yield* DesktopSnapShot.DesktopSnapShot; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + // Persist before waiting; shortcut setup needs the shell's session bus. yield* clientSettings.set(settings); + yield* shellEnvironment.installIntoProcess; yield* snapShot.configure(settings); }), }); diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index b2212609030d1..4c1f2015b2da8 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -17,6 +17,7 @@ export const getUpdateState = DesktopIpc.makeIpcMethod({ result: DesktopUpdateStateSchema, handler: Effect.fn("desktop.ipc.updates.getState")(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.whenConfigured; return yield* updates.getState; }), }); @@ -27,6 +28,7 @@ export const setUpdateChannel = DesktopIpc.makeIpcMethod({ result: DesktopUpdateStateSchema, handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) { const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.whenConfigured; return yield* updates.setChannel(channel); }), }); @@ -37,6 +39,7 @@ export const downloadUpdate = DesktopIpc.makeIpcMethod({ result: DesktopUpdateActionResultSchema, handler: Effect.fn("desktop.ipc.updates.download")(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.whenConfigured; return yield* updates.download; }), }); @@ -47,6 +50,7 @@ export const installUpdate = DesktopIpc.makeIpcMethod({ result: DesktopUpdateActionResultSchema, handler: Effect.fn("desktop.ipc.updates.install")(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.whenConfigured; return yield* updates.install; }), }); @@ -57,6 +61,7 @@ export const checkForUpdate = DesktopIpc.makeIpcMethod({ result: DesktopUpdateCheckResultSchema, handler: Effect.fn("desktop.ipc.updates.check")(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.whenConfigured; return yield* updates.check("web-ui"); }), }); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3056ce1ea2637..8d183a63e845e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -186,6 +186,7 @@ const layerDesktopAppActivation = DesktopAppActivation.layer.pipe( const layerDesktopBackend = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), + Layer.provideMerge(DesktopShellEnvironment.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), Layer.provideMerge(DesktopShellEnvironment.layer), Layer.provideMerge(DesktopWslEnvironment.layer), diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index aee5a0bc81b54..19d8f9b2844a5 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -13,9 +13,17 @@ import * as NodeOS from "node:os"; import { vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopPreReadyPlatform from "../app/DesktopPreReadyPlatform.ts"; import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; vi.mock("node:os", { spy: true }); +const { appendSwitch, removeSwitch } = vi.hoisted(() => ({ + appendSwitch: vi.fn(), + removeSwitch: vi.fn(), +})); +vi.mock("electron", () => ({ + app: { commandLine: { appendSwitch, removeSwitch } }, +})); const textEncoder = new TextEncoder(); @@ -76,6 +84,8 @@ function runShellEnvironment(input: { command: ChildProcess.Command, ) => string | { output: string; exitCode: number }; readonly failure?: PlatformError.PlatformError; + readonly secretStorage?: boolean; + readonly passwordStoreCommandLine?: string; }) { const layerEnvironment = Layer.succeed( DesktopEnvironment.DesktopEnvironment, @@ -98,13 +108,27 @@ function runShellEnvironment(input: { const program = Effect.gen(function* () { const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + if (input.secretStorage) { + yield* shellEnvironment.secretStorageReady; + yield* shellEnvironment.secretStorageReady; + } const prepared = yield* shellEnvironment.installIntoProcess; assert.equal(yield* shellEnvironment.installIntoProcess, prepared); return prepared; }).pipe( Effect.provide( DesktopShellEnvironment.layer.pipe( - Layer.provide(Layer.mergeAll(layerEnvironment, NodeServices.layer, layerSpawner)), + Layer.provide( + Layer.mergeAll( + layerEnvironment, + NodeServices.layer, + layerSpawner, + Layer.succeed(DesktopPreReadyPlatform.DesktopPreReadyElectronOptions, { + linux: null, + linuxPasswordStoreCommandLine: input.passwordStoreCommandLine ?? null, + }), + ), + ), ), ), ); @@ -122,6 +146,53 @@ function runShellEnvironment(input: { } describe("DesktopShellEnvironment", () => { + it.effect.each([ + { bus: undefined, label: "missing" }, + { bus: "unix:path=/tmp/stale-bus", label: "stale" }, + ])("selects secret storage after shell hydration with a $label bus", ({ bus }) => { + appendSwitch.mockClear(); + removeSwitch.mockClear(); + const env = { + PATH: "/usr/bin", + DBUS_SESSION_BUS_ADDRESS: bus, + XDG_CURRENT_DESKTOP: "GNOME", + }; + return runShellEnvironment({ + env, + platform: "linux", + secretStorage: true, + handler: () => + envOutput({ + PATH: "/usr/bin", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/hydrated-bus", + XDG_CURRENT_DESKTOP: "LXQt", + }), + }).pipe( + Effect.map(() => { + assert.equal(env.DBUS_SESSION_BUS_ADDRESS, "unix:path=/tmp/hydrated-bus"); + assert.deepEqual(removeSwitch.mock.calls, [["password-store"]]); + assert.deepEqual(appendSwitch.mock.calls, [["password-store", "gnome-libsecret"]]); + }), + ); + }); + + it.effect("preserves an explicit password-store after shell hydration", () => { + appendSwitch.mockClear(); + removeSwitch.mockClear(); + return runShellEnvironment({ + env: { PATH: "/usr/bin", DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/stale-bus" }, + platform: "linux", + secretStorage: true, + passwordStoreCommandLine: "basic", + handler: () => envOutput({ PATH: "/usr/bin", XDG_CURRENT_DESKTOP: "LXQt" }), + }).pipe( + Effect.map(() => { + assert.deepEqual(removeSwitch.mock.calls, []); + assert.deepEqual(appendSwitch.mock.calls, []); + }), + ); + }); + it.effect("rejects PATH output from a failed shell and tries the next candidate", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { SHELL: "/bin/failing", PATH: "/usr/bin" }; diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 8fa8a60b2e62b..37d6ad9826878 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -9,8 +9,11 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as Electron from "electron"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopPreReadyPlatform from "../app/DesktopPreReadyPlatform.ts"; type EnvironmentPatch = Record; @@ -64,8 +67,10 @@ export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErr export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, { - // Reuses the installation result; false leaves server hydration enabled. + /** Imports the login shell's environment into process.env once; later calls wait for that run. */ readonly installIntoProcess: Effect.Effect; + /** Linux safeStorage waits for the hydrated session and password-store selection. */ + readonly secretStorageReady: Effect.Effect; } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} @@ -534,6 +539,8 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const preReadyOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + const homeDirectory = yield* HostProcess.HomeDirectory; const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = yield* Effect.cached( installShellEnvironment({ @@ -546,7 +553,25 @@ export const make = Effect.gen(function* () { ), ); - return DesktopShellEnvironment.of({ installIntoProcess }); + const secretStorageReady = yield* Effect.cached( + Effect.gen(function* () { + if (environment.platform !== "linux") return; + yield* installIntoProcess; + if (preReadyOptions.linuxPasswordStoreCommandLine !== null) return; + const linux = + DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess(homeDirectory); + // safeStorage selects lazily on first use. The shell can correct desktop + // hints or the settings path even when a session bus was already present. + if (linux.passwordStore !== null || preReadyOptions.linux?.passwordStore !== null) { + Electron.app.commandLine.removeSwitch("password-store"); + } + if (linux.passwordStore !== null) { + Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore); + } + }), + ); + + return DesktopShellEnvironment.of({ installIntoProcess, secretStorageReady }); }); export const layer = Layer.effect(DesktopShellEnvironment, make); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index b2d93f407ac76..6c8c44b8cc0cd 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -11,6 +11,7 @@ import { errorTag } from "@t3tools/shared/observability"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -183,6 +184,8 @@ export class DesktopUpdates extends Context.Service< readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; readonly configure: Effect.Effect; + /** Waits until `configure` has run, so the channel and availability are settled. */ + readonly whenConfigured: Effect.Effect; readonly setChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; @@ -351,6 +354,7 @@ export const make = Effect.gen(function* () { >([]); const finishedUpdateActions = yield* PubSub.unbounded(); const updaterConfiguredRef = yield* Ref.make(false); + const configureDone = yield* Deferred.make(); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); const notAppliedVersionRef = yield* Ref.make(Option.none()); const updateStateRef = yield* Ref.make( @@ -1100,7 +1104,11 @@ export const make = Effect.gen(function* () { }); yield* startUpdatePollers; - }).pipe(Effect.withSpan("desktop.updates.configure")), + }).pipe( + Effect.ensuring(Deferred.succeed(configureDone, undefined)), + Effect.withSpan("desktop.updates.configure"), + ), + whenConfigured: Deferred.await(configureDone), setChannel: Effect.fn("desktop.updates.setChannel")(function* ( nextChannel: DesktopUpdateChannel, ) { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index fa3960a34ee5d..1a80af864bc92 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import type * as Electron from "electron"; @@ -57,7 +58,7 @@ const layerElectronDialog = Layer.succeed(ElectronDialog.ElectronDialog, { showErrorBox: () => Effect.void, } satisfies ElectronDialog.ElectronDialog["Service"]); -const layerDesktopUpdates = Layer.succeed(DesktopUpdates.DesktopUpdates, { +const desktopUpdates = { getState: Effect.die("unexpected getState"), isActionActive: Effect.succeed(false), isInstallActive: Effect.succeed(false), @@ -65,12 +66,15 @@ const layerDesktopUpdates = Layer.succeed(DesktopUpdates.DesktopUpdates, { emitState: Effect.void, disabledReason: Effect.succeedNone, configure: Effect.void, + whenConfigured: Effect.void, setChannel: () => Effect.die("unexpected setChannel"), check: () => Effect.die("unexpected check"), download: Effect.die("unexpected download"), install: Effect.die("unexpected install"), installPrepared: () => Effect.die("unexpected installPrepared"), -} satisfies DesktopUpdates.DesktopUpdates["Service"]); +} satisfies DesktopUpdates.DesktopUpdates["Service"]; +const layerDesktopUpdates = (updates: Partial) => + Layer.succeed(DesktopUpdates.DesktopUpdates, { ...desktopUpdates, ...updates }); const layerDesktopWindow = (selectedAction: Deferred.Deferred) => Layer.succeed(DesktopWindow.DesktopWindow, { @@ -107,6 +111,7 @@ const configureMenu = ( selectedAction: Deferred.Deferred, applicationMenuTemplate: Deferred.Deferred, environment: Partial = {}, + updates: Partial = {}, ) => Effect.gen(function* () { const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; @@ -116,7 +121,7 @@ const configureMenu = ( DesktopApplicationMenu.layer.pipe( Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)), Layer.provideMerge(layerDesktopWindow(selectedAction)), - Layer.provideMerge(layerDesktopUpdates), + Layer.provideMerge(layerDesktopUpdates(updates)), Layer.provideMerge(layerElectronDialog), Layer.provideMerge(layerElectronApp), Layer.provideMerge( @@ -334,4 +339,44 @@ describe("DesktopApplicationMenu", () => { assert.equal(yield* Deferred.await(selectedAction), "main-reload"); }), ); + + it.effect("waits for the updater's configuration before a menu update check", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + const calls: Array = []; + const checked = yield* Deferred.make(); + + yield* configureMenu( + selectedAction, + applicationMenuTemplate, + {}, + { + whenConfigured: Effect.sync(() => { + calls.push("whenConfigured"); + }), + disabledReason: Effect.sync(() => { + calls.push("disabledReason"); + }).pipe( + Effect.andThen(Deferred.succeed(checked, undefined)), + Effect.as(Option.some("Updates are off in this test.")), + ), + }, + ); + + const template = yield* Deferred.await(applicationMenuTemplate); + const checkForUpdates = template + .flatMap((item) => (Array.isArray(item.submenu) ? item.submenu : [])) + .find((item) => item.label === "Check for Updates..."); + assert.isFunction(checkForUpdates?.click); + checkForUpdates!.click!( + {} as Electron.MenuItem, + {} as Electron.BrowserWindow, + {} as KeyboardEvent, + ); + yield* Deferred.await(checked); + assert.deepStrictEqual(calls, ["whenConfigured", "disabledReason"]); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index c51e13e586732..c5eddfba2f576 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -91,6 +91,8 @@ const checkForUpdatesFromMenu = Effect.gen(function* () { const handleCheckForUpdatesMenuClick = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; + // The menu exists before the updater is configured; a click then waits for it. + yield* updates.whenConfigured; const disabledReason = yield* updates.disabledReason; if (Option.isSome(disabledReason)) { yield* logUpdaterInfo("manual update check requested, but updates are disabled", { diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index c7cf8a61e300b..a380252714d9c 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -52,7 +52,7 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string { const parts: string[] = []; if (gitStatus.hasWorkingTreeChanges) { - parts.push(`${gitStatus.workingTree.files.length} changed`); + parts.push(`${gitStatus.workingTree.fileCount ?? gitStatus.workingTree.files.length} changed`); } else if (gitStatus.aheadCount === 0 && gitStatus.behindCount === 0) { parts.push("Clean"); } diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index d0cf3c345f078..1e9ae1d73c8c9 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -32,18 +32,27 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const { canWriteSourceControl, canChangeThreadBranch } = gitActions; + const summary = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null ? vcsEnvironment.status({ environmentId: selectedThread.environmentId, - input: { cwd: selectedThreadCwd }, + input: { cwd: selectedThreadCwd, includeFiles: true }, }) : null, ); const busy = gitState.gitOperationLabel !== null; - const isDefaultRef = gitStatus.data?.isDefaultRef ?? false; + const status = gitStatus.data ?? summary.data; + const isDefaultRef = status?.isDefaultRef ?? false; const allFiles = gitStatus.data?.workingTree?.files ?? []; const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -108,7 +117,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { Branch - {gitStatus.data?.refName ?? "(detached HEAD)"} + {status?.refName ?? "(detached HEAD)"} {isDefaultRef ? ( @@ -125,8 +134,9 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { Files - {selectedFiles.length} selected · +{formatDiffCount(selectedInsertions)} / - - {formatDiffCount(selectedDeletions)} + {gitStatus.isPending && gitStatus.data === null + ? "Loading files..." + : `${selectedFiles.length} selected · +${formatDiffCount(selectedInsertions)} / -${formatDiffCount(selectedDeletions)}`} @@ -153,7 +163,11 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {allFiles.length === 0 ? ( - No changed files are available to commit. + {gitStatus.error ?? + // A live status stays pending after each value, so only a missing one is loading. + (gitStatus.isPending && gitStatus.data === null + ? "Loading files..." + : "No changed files are available to commit.")} ) : !isEditingFiles ? ( diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 6e131d12dd9f9..48c2f6d4d7fbf 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -202,7 +202,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { return undefined; } if (item.dialogAction === "commit" && status.hasWorkingTreeChanges) { - const fileCount = status.workingTree?.files.length ?? 0; + const fileCount = status.workingTree?.fileCount ?? status.workingTree?.files.length ?? 0; return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`; } if (item.dialogAction === "push" && (status.aheadCount ?? 0) > 0) { diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index 53d5acef17172..f6d323b2c1562 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -136,7 +136,10 @@ export function statusSummary( gitStatus: { readonly isRepo?: boolean; readonly hasWorkingTreeChanges?: boolean; - readonly workingTree?: { readonly files: readonly { readonly path: string }[] }; + readonly workingTree?: { + readonly files: readonly { readonly path: string }[]; + readonly fileCount?: number; + }; readonly aheadCount?: number; readonly behindCount?: number; readonly pr?: { readonly state?: string; readonly number?: number } | null; @@ -152,7 +155,7 @@ export function statusSummary( const parts: string[] = []; if (gitStatus.hasWorkingTreeChanges) { - const fileCount = gitStatus.workingTree?.files.length ?? 0; + const fileCount = gitStatus.workingTree?.fileCount ?? gitStatus.workingTree?.files.length ?? 0; parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"} changed`); } else { parts.push("Clean"); diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index d8bf059852b12..10f4e738c8c43 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -67,7 +67,7 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio } yield* sql`INSERT INTO orchestration_v2_projection_provider_sessions (provider_session_id, thread_id, provider, status, updated_at, payload_json) - VALUES ('session-shared', 'running-thread', 'codex', 'stopped', '2026-08-01', '{}')`; + VALUES ('session-shared', 'running-thread', 'codex', 'ready', '2026-08-01', '{"status":"ready"}')`; yield* sql`INSERT INTO orchestration_v2_projection_provider_session_bindings (provider_session_id, thread_id) VALUES ('session-shared', 'running-thread'), ('session-shared', 'stopped-thread')`; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 71cd0ced16d8d..93b74cbc362ce 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -280,6 +280,14 @@ const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { const sql = yield* SqlClient.SqlClient; + // A ready session is an idle reusable provider handle, not executing work. + // Old installs bind that handle to many completed threads. Stop only those + // idle handles in the copy before asking the runtime recovery queries which + // threads could resume work; active runs/provider threads still exclude them. + yield* sql`UPDATE orchestration_v2_projection_provider_sessions + SET status = 'stopped', payload_json = json_set(payload_json, '$.status', 'stopped') + WHERE status = 'ready'`; + // Threads the server would resume or start work on by itself. Its own // recovery queries find most of them; usage-limit recovery also depends on // settings, so any thread whose latest run failed or that has a recovery diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 4e01009c77c4a..c5a2cb18fd149 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -4,6 +4,9 @@ * Both dispatch here before the full CLI module graph (seconds of evaluation) loads; everything * else defers to the real CLI in ./binCli.ts. */ +import packageJson from "../package.json" with { type: "json" }; +import * as NodeSea from "node:sea"; +import { enableCliCompileCache } from "./cliCompileCache.ts"; import { isEntrypoint } from "./entrypoint.ts"; if ( @@ -14,9 +17,46 @@ if ( }) ) { const command = process.argv[2]; - if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { + // Helpers omit --base-dir; browser setup may run as root. Avoid caching in the wrong home. + const uncached = + command === "__spawn-host" || + command === "__load-bundle" || + command === "__claude-history" || + command === "acp-mcp-bridge" || + command === "acp-mcp-call" || + command === "browser"; + if (!(process.argv.length === 3 && command === "--version") && !uncached) { + enableCliCompileCache({ + bundled: NodeSea.isSea() || import.meta.url.endsWith(".mjs"), + argv: process.argv.slice(2), + environment: process.env, + version: packageJson.version, + }); + } + if (process.argv.length === 3 && command === "--version") { + process.stdout.write(`t3 v${packageJson.version}\n`); + } else if ( + command === "pair" || + command === "auth" || + command === "service" || + command === "project" || + command === "serve" || + command === "start" + ) { + const { runLightCli } = await import("./binLightCli.ts"); + await runLightCli(command); + } else if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts"); await runAcpMcpCliFastPath(command, process.argv.slice(3)); + } else if (command === "__spawn-host") { + const { runSpawnHost } = await import("./process/spawnHostProcess.ts"); + runSpawnHost(); + } else if (command === "__load-bundle") { + const { loadBundle } = await import("./loadBundle.ts"); + const chunks = await loadBundle(import.meta.url); + process.stdout.write(`t3 v${packageJson.version}: loaded ${chunks} bundle chunks\n`); + // A chunk may start timers or handles at load, as it would in the server. + process.exit(0); } else { // Before anything reads stdin: the desktop app's Windows launcher starts // the CLI without one. Never for the fast paths, whose stdin is a pipe. diff --git a/apps/server/src/binLightCli.ts b/apps/server/src/binLightCli.ts new file mode 100644 index 0000000000000..86db7f56039ee --- /dev/null +++ b/apps/server/src/binLightCli.ts @@ -0,0 +1,34 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Command } from "effect/cli"; +import * as NetService from "@t3tools/shared/Net"; +import packageJson from "../package.json" with { type: "json" }; +import { sharedServerCommandFlags } from "./cli/config.ts"; + +export async function runLightCli( + command: "pair" | "auth" | "service" | "project" | "serve" | "start", +) { + const selected = + command === "pair" + ? (await import("./cli/pair.ts")).pairCommand + : command === "auth" + ? (await import("./cli/auth.ts")).authCommand + : command === "service" + ? (await import("./cli/service.ts")).serviceCommand + : command === "project" + ? (await import("./cli/project.ts")).projectCommand + : command === "serve" + ? (await import("./cli/server.ts")).serveCommand + : (await import("./cli/server.ts")).startCommand; + const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withSubcommands([selected]), + ); + Command.run(cli, { version: packageJson.version }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer)), + NodeRuntime.runMain, + ); +} diff --git a/apps/server/src/cli/claudeHistory.ts b/apps/server/src/cli/claudeHistory.ts index c23bb817b4c03..1f247cac90adf 100644 --- a/apps/server/src/cli/claudeHistory.ts +++ b/apps/server/src/cli/claudeHistory.ts @@ -1,8 +1,6 @@ import * as Effect from "effect/Effect"; import { Argument, Command } from "effect/cli"; -import { runClaudeHistoryWorker } from "../claudeHistoryWorker.ts"; - /** * The npm bundle runs it as a sibling `claudeHistoryWorker.mjs` under the host * Node; the single-executable has no Node to run a script with, so the adapter @@ -15,12 +13,13 @@ export const claudeHistoryCommand = Command.make("__claude-history", { }).pipe( Command.unlisted, Command.withHandler(({ method, sessionId, options }) => - Effect.promise(() => - runClaudeHistoryWorker( + Effect.promise(async () => { + const { runClaudeHistoryWorker } = await import("../claudeHistoryWorker.ts"); + return runClaudeHistoryWorker( method, sessionId, options._tag === "Some" ? options.value : undefined, - ), - ), + ); + }), ), ); diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 723e77144efb0..0195ae71c7d5a 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -10,7 +10,6 @@ import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { buildTailscaleHttpsBaseUrl, DEFAULT_TAILSCALE_SERVE_PORT, - ensureTailscaleServe, readTailscaleStatus, } from "@t3tools/tailscale"; import * as Config from "effect/Config"; @@ -372,6 +371,7 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase" if (isDevServerNotProxiableError(localTarget)) { return yield* localTarget; } + const { ensureTailscaleServe } = yield* Effect.promise(() => import("@t3tools/tailscale")); yield* ensureTailscaleServe({ localPort: localTarget.localPort, servePort: input.servePort, diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index fb7a386059d67..4e99626a98d44 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -1,3 +1,4 @@ +import * as ProjectLayers from "../orchestration-v2/runtimeLayer.ts"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; // @effect-diagnostics nodeBuiltinImport:off - CLI integration uses temporary Node paths. @@ -31,7 +32,6 @@ import * as ServerConfig from "../config.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; -import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; @@ -89,7 +89,7 @@ const makeConfig = (baseDir: string) => const readProjects = (baseDir: string) => Effect.gen(function* () { const config = yield* makeConfig(baseDir); - const layer = RuntimeLayer.layerProjectService.pipe( + const layer = ProjectLayers.layerProjectService.pipe( Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(ProjectFaviconResolver.layer), @@ -171,7 +171,7 @@ const makeThreadPersistenceLayer = Effect.fn("ProjectCliTest.makeThreadPersisten function* (baseDir: string) { const config = yield* makeConfig(baseDir); return Layer.mergeAll( - RuntimeLayer.layerEventSink, + ProjectLayers.layerEventSink, ProjectionStore.layer, EventStore.layer, ).pipe( diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 8f352489b885f..b5daf11904c77 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -28,7 +28,6 @@ import * as ServerConfig from "../config.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerOwnership from "../serverOwnership.ts"; -import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; @@ -200,7 +199,11 @@ const projectCommandUuid = Crypto.Crypto.pipe( ), ); -const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe( +const layerProjectCliRuntime = Layer.unwrap( + Effect.promise(() => import("../orchestration-v2/runtimeLayer.ts")).pipe( + Effect.map((runtime) => runtime.layerProjectService), + ), +).pipe( Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge( diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index b43201cd296b6..70f4b3051a776 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -8,7 +8,6 @@ import * as CliError from "effect/cli/CliError"; import * as ServerConfig from "../config.ts"; import { awaitLauncherLoss } from "../launcherWatch.ts"; -import { runServer } from "../server.ts"; import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts"; const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); @@ -24,6 +23,7 @@ const runServerCommand = ( Effect.gen(function* () { const logLevel = yield* GlobalFlag.LogLevel; const config = yield* resolveServerConfig(flags, logLevel, options); + const { runServer } = yield* Effect.promise(() => import("../server.ts")); const run = runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config)); if (config.mode !== "desktop" || config.desktopBootstrapToken === undefined) return yield* run; return yield* Effect.raceFirst( diff --git a/apps/server/src/cliCompileCache.ts b/apps/server/src/cliCompileCache.ts new file mode 100644 index 0000000000000..3815690b7ae0c --- /dev/null +++ b/apps/server/src/cliCompileCache.ts @@ -0,0 +1,61 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Runs before loading the Effect CLI or server graph. +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +const UNUSED_VERSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +/** Cache bundled code under the selected T3 home; leave source dev uncached. */ +export function enableCliCompileCache(input: { + readonly bundled: boolean; + readonly argv: readonly string[]; + readonly environment: NodeJS.ProcessEnv; + readonly version: string; +}) { + if ( + !input.bundled || + // AppImage mount paths change each launch, so path-keyed entries never hit. + input.environment.APPIMAGE || + input.environment.NODE_DISABLE_COMPILE_CACHE === "1" || + input.environment.NODE_COMPILE_CACHE + ) + return; + const index = input.argv.indexOf("--base-dir"); + const configured = + (index >= 0 ? input.argv[index + 1] : undefined) ?? + input.argv + .find((argument) => argument.startsWith("--base-dir=")) + ?.slice("--base-dir=".length) ?? + input.environment.T3CODE_HOME; + const home = NodeOS.homedir(); + const base = configured?.trim(); + const directory = !base + ? NodePath.join(home, ".t3") + : base === "~" + ? home + : base.startsWith("~/") || base.startsWith("~\\") + ? NodePath.join(home, base.slice(2)) + : NodePath.resolve(base); + const root = NodePath.join(directory, "compile-cache"); + // Cache failures never prevent CLI or server startup, including a read-only home. + try { + const current = NodePath.join(root, input.version); + const { status } = NodeModule.enableCompileCache(current); + if (status !== NodeModule.constants.compileCacheStatus.ENABLED) return; + // Other installed versions share this home; remove only caches unused for a week. + // @effect-diagnostics-next-line globalDate:off -- Runs before the Effect runtime loads. + const now = new Date(); + NodeFS.utimes(current, now, now, () => {}); + const cutoff = now.getTime() - UNUSED_VERSION_MAX_AGE_MS; + for (const entry of NodeFS.readdirSync(root)) { + if (entry === input.version) continue; + const path = NodePath.join(root, entry); + NodeFS.stat(path, (error, stats) => { + if (error === null && stats.mtimeMs < cutoff) { + NodeFS.rm(path, { recursive: true, force: true }, () => {}); + } + }); + } + } catch {} +} diff --git a/apps/server/src/compileCache.test.ts b/apps/server/src/compileCache.test.ts index 17827bb38ba95..7839bf67e45c1 100644 --- a/apps/server/src/compileCache.test.ts +++ b/apps/server/src/compileCache.test.ts @@ -51,3 +51,98 @@ process.kill(process.pid, "SIGKILL");`, } }, ); + +it.each([ + { disabledBy: undefined }, + { disabledBy: "NODE_DISABLE_COMPILE_CACHE" }, + { disabledBy: "APPIMAGE" }, +] as const)( + "selects the CLI cache under the explicit T3 home (disabled by: $disabledBy)", + async ({ disabledBy }) => { + const disabled = disabledBy !== undefined; + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-cli-cache-")); + try { + const child = NodeChildProcess.spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + ` +const { enableCliCompileCache } = await import(${JSON.stringify(new URL("./cliCompileCache.ts", import.meta.url).href)}); +enableCliCompileCache({ bundled: true, argv: ["serve", "--base-dir", ${JSON.stringify(directory)}], environment: process.env, version: "1.2.3" }); +await import(${JSON.stringify(import.meta.resolve("effect/Effect"))}); +const { flushCompileCache } = await import(${JSON.stringify(new URL("./compileCache.ts", import.meta.url).href)}); +const Effect = await import(${JSON.stringify(import.meta.resolve("effect/Effect"))}); +await Effect.runPromise(flushCompileCache); +process.kill(process.pid, "SIGKILL");`, + ], + { + encoding: "utf8", + env: { + ...process.env, + NODE_COMPILE_CACHE: undefined, + NODE_DISABLE_COMPILE_CACHE: + disabledBy === "NODE_DISABLE_COMPILE_CACHE" ? "1" : undefined, + APPIMAGE: disabledBy === "APPIMAGE" ? "/opt/T3-Code.AppImage" : undefined, + }, + }, + ); + assert.equal(child.error, undefined); + assert.equal(child.stderr, ""); + const cacheDirectory = NodePath.join(directory, "compile-cache", "1.2.3"); + const entries = await NodeFSP.readdir(cacheDirectory, { + recursive: true, + }).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return []; + throw error; + }); + const files = await Promise.all( + entries.map((entry) => NodeFSP.stat(NodePath.join(cacheDirectory, entry))), + ); + assert.equal( + files.some((entry) => entry.isFile()), + !disabled, + ); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }, +); + +it("removes another version's CLI cache directory only after a week unused", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-cli-cache-")); + try { + const root = NodePath.join(directory, "compile-cache"); + for (const version of ["1.2.2", "1.2.4"]) { + await NodeFSP.mkdir(NodePath.join(root, version, "node"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(root, version, "node", "entry"), version); + } + // 1.2.2 was last used eight days ago; 1.2.4 is another install still in use. + const { mtimeMs } = await NodeFSP.stat(NodePath.join(root, "1.2.2")); + const eightDaysAgo = (mtimeMs - 8 * 24 * 60 * 60 * 1000) / 1000; + await NodeFSP.utimes(NodePath.join(root, "1.2.2"), eightDaysAgo, eightDaysAgo); + const child = NodeChildProcess.spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + ` +const { enableCliCompileCache } = await import(${JSON.stringify(new URL("./cliCompileCache.ts", import.meta.url).href)}); +enableCliCompileCache({ bundled: true, argv: ["serve", "--base-dir", ${JSON.stringify(directory)}], environment: process.env, version: "1.2.3" });`, + ], + { + encoding: "utf8", + env: { + ...process.env, + NODE_COMPILE_CACHE: undefined, + NODE_DISABLE_COMPILE_CACHE: undefined, + }, + }, + ); + assert.equal(child.error, undefined); + assert.equal(child.stderr, ""); + assert.deepEqual((await NodeFSP.readdir(root)).sort(), ["1.2.3", "1.2.4"]); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/loadBundle.ts b/apps/server/src/loadBundle.ts new file mode 100644 index 0000000000000..e5c53974fdcd1 --- /dev/null +++ b/apps/server/src/loadBundle.ts @@ -0,0 +1,33 @@ +// @effect-diagnostics nodeBuiltinImport:off - runs from bin.ts before any Effect runtime. +// Desktop packaging uses this to catch missing lazy chunks that --version skips. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const CHUNK_IMPORT = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)["']\.\/([^"'/]+\.mjs)["']/g; + +export async function loadBundle(entryUrl: string): Promise { + if (!entryUrl.endsWith(".mjs")) { + throw new Error("t3 __load-bundle checks the built bundle; run it from dist/bin.mjs."); + } + const entryPath = NodeURL.fileURLToPath(entryUrl); + const directory = NodePath.dirname(entryPath); + const entry = NodePath.basename(entryPath); + const files = [entry]; + const seen = new Set(files); + for (const file of files) { + // The entry is still in its top-level await, so importing it again would wait on itself. + if (file !== entry) await import(NodeURL.pathToFileURL(NodePath.join(directory, file)).href); + const source = NodeFS.readFileSync(NodePath.join(directory, file), "utf8"); + for (const [, chunk] of source.matchAll(CHUNK_IMPORT)) { + if (chunk && !seen.has(chunk)) { + seen.add(chunk); + files.push(chunk); + } + } + } + // Cursor's disk-backed SDK loads on first call, outside the chunk graph. + const { loadCursorSdk } = await import("@t3tools/provider-cursor/server/sdk"); + loadCursorSdk(); + return files.length - 1; +} diff --git a/apps/server/src/observability/EventLoopMonitor.test.ts b/apps/server/src/observability/EventLoopMonitor.test.ts index 963279d138247..726af2434cf65 100644 --- a/apps/server/src/observability/EventLoopMonitor.test.ts +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -11,7 +11,7 @@ import * as EventLoopMonitor from "./EventLoopMonitor.ts"; const ms = (value: number) => value * 1e6; -// Node reports a stall of S as a gap of up to S + 1 s, the histogram resolution. +// Node reports a stall of S as a gap of up to S + 100 ms, the histogram resolution. const stalled: EventLoopMonitor.EventLoopReadings = { delayMaxNs: ms(5_950), activeMs: 6_200, @@ -25,7 +25,8 @@ const stalled: EventLoopMonitor.EventLoopReadings = { }, rssBytes: 1536 * 1024 * 1024, }; -const quiet: EventLoopMonitor.EventLoopReadings = { ...stalled, delayMaxNs: ms(2_950) }; +// Over the threshold as read, but not once the resolution is subtracted. +const quiet: EventLoopMonitor.EventLoopReadings = { ...stalled, delayMaxNs: ms(300) }; const monitor = ( samples: Array, @@ -51,18 +52,20 @@ describe("EventLoopMonitor", () => { }); yield* Effect.gen(function* () { yield* monitor([stalled, quiet, stalled]); - yield* TestClock.adjust("60 seconds"); - assert.lengthOf(spans, 0); - yield* TestClock.adjust("30 seconds"); + yield* TestClock.adjust("1 second"); + assert.lengthOf(spans, 1); + yield* TestClock.adjust("1 second"); + assert.lengthOf(spans, 1); + yield* TestClock.adjust("1 second"); }).pipe(Effect.scoped, Effect.withTracer(tracer)); assert.deepStrictEqual( spans.map((span) => span.name), - ["server.eventLoop.stall"], + ["server.eventLoop.stall", "server.eventLoop.stall"], ); const [span] = spans; assert.deepStrictEqual(Object.fromEntries(span!.attributes), { - delayMaxMs: 4_950, + delayMaxMs: 5_850, utilization: 0.18, cpuUserMs: 310, cpuSystemMs: 96, @@ -73,7 +76,7 @@ describe("EventLoopMonitor", () => { }); assert.deepStrictEqual( span!.events.map(([name, , attributes]) => [name, attributes["effect.logLevel"]]), - [["event loop stalled for 4950 ms", "WARN"]], + [["event loop stalled for 5850 ms", "WARN"]], ); }), ); @@ -87,15 +90,15 @@ describe("EventLoopMonitor", () => { filePath, maxBytes: 300, }); - yield* TestClock.adjust("120 seconds"); + yield* TestClock.adjust("4 seconds"); const lines = (yield* fs.readFileString(filePath)).trim().split("\n"); assert.deepStrictEqual( lines.map((line) => JSON.parse(line)), [ { - time: "1970-01-01T00:02:00.000Z", - delayMaxMs: 6_000, + time: "1970-01-01T00:00:04.000Z", + delayMaxMs: 6_900, utilization: 0.18, cpuUserMs: 310, cpuSystemMs: 96, @@ -120,9 +123,9 @@ describe("EventLoopMonitor", () => { const fs = yield* FileSystem.FileSystem; const filePath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-event-loop-" }); yield* monitor([stalled, stalled], { filePath, maxBytes: 1024 * 1024 }); - yield* TestClock.adjust("60 seconds"); + yield* TestClock.adjust("1 second"); assert.deepStrictEqual(warnings, [ - "event loop stalled for 4950 ms", + "event loop stalled for 5850 ms", "event loop stall log write failed", ]); }).pipe( @@ -133,7 +136,56 @@ describe("EventLoopMonitor", () => { ); }); - it("ignores delay the loop spent idle, such as a system sleep", () => { + it.effect("samples every second at startup and every 30 seconds after", () => + Effect.gen(function* () { + let samples = 0; + yield* Layer.build( + EventLoopMonitor.layerWith(Effect.succeed(Effect.sync(() => (++samples, quiet))), { + filePath: "/missing/event-loop-stalls.ndjson", + maxBytes: 1024, + }), + ); + yield* TestClock.adjust("30 seconds"); + assert.strictEqual(samples, 30); + yield* TestClock.adjust("29 seconds"); + assert.strictEqual(samples, 30); + yield* TestClock.adjust("1 second"); + assert.strictEqual(samples, 31); + }).pipe(Effect.scoped), + ); + + it("reports only stalls over 2 s after startup", () => { + // Read at the steady 1 s resolution. + assert.isUndefined( + EventLoopMonitor.stallMs({ ...stalled, delayMaxNs: ms(2_900), steady: true }), + ); + assert.strictEqual( + EventLoopMonitor.stallMs({ ...stalled, delayMaxNs: ms(4_000), steady: true }), + 3_000, + ); + }); + + it("reports short stalls even when ELU active time lags the delay reading", () => { + const short: EventLoopMonitor.EventLoopReadings = { + ...stalled, + delayMaxNs: ms(850), + activeMs: 100, + }; + assert.strictEqual(EventLoopMonitor.stallMs(short), 750); + // Before the loop starts, ELU reads 0 and CPU time shows the loop was busy. + const busyBeforeLoop = { + ...quiet, + activeMs: 0, + usage: { ...quiet.usage, userCPUTime: 52_000_000, systemCPUTime: 900_000 }, + }; + assert.strictEqual( + EventLoopMonitor.stallMs({ ...busyBeforeLoop, tickDelayMs: 53_000 }), + 53_000, + ); + assert.isUndefined(EventLoopMonitor.stallMs(quiet)); + }); + + it("ignores a long gap the loop spent idle, such as a system sleep", () => { // Waking from sleep reads as a long gap, but the loop was idle in poll for it. const asleep: EventLoopMonitor.EventLoopReadings = { ...stalled, @@ -141,6 +193,7 @@ describe("EventLoopMonitor", () => { activeMs: 900, }; assert.isUndefined(EventLoopMonitor.stallMs(asleep)); - assert.strictEqual(EventLoopMonitor.stallMs({ ...asleep, activeMs: 600_000 }), 599_000); + assert.isUndefined(EventLoopMonitor.stallMs({ ...quiet, tickDelayMs: 7_200_000 })); + assert.strictEqual(EventLoopMonitor.stallMs({ ...asleep, activeMs: 600_000 }), 599_900); }); }); diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts index d713e71940dec..a7c33ceb29c64 100644 --- a/apps/server/src/observability/EventLoopMonitor.ts +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -11,18 +11,19 @@ import type * as Scope from "effect/Scope"; import * as ServerConfig from "../config.ts"; -// Node's delay histogram wakes a native timer every RESOLUTION_MS and records the -// gap between wakeups, so an idle loop reads about RESOLUTION_MS and a stall of S -// reads between S and S + RESOLUTION_MS. We subtract the resolution, so a delay can -// undercount a stall by up to RESOLUTION_MS. -const RESOLUTION_MS = 1000; -const STALL_THRESHOLD_MS = 2000; -const SAMPLE_INTERVAL = "30 seconds"; +// Subtract the histogram resolution to exclude idle delay; stalls can undercount by that much. +// Sample startup more closely, then reduce wakeups so an idle backend stays idle. +const STARTUP = { resolutionMs: 100, stallThresholdMs: 250, sampleIntervalMs: 1000 }; +const STEADY = { resolutionMs: 1000, stallThresholdMs: 2000, sampleIntervalMs: 30_000 }; +const STARTUP_SAMPLES = 30; +const SLEEP_CHECK_MS = 5000; const STALL_LOG_MAX_BYTES = 256 * 1024; /** Delay in ns, active time in ms, CPU in µs. */ export interface EventLoopReadings { readonly delayMaxNs: number; + /** Timer drift also covers startup and the histogram's first gap after reset. */ + readonly tickDelayMs?: number; readonly activeMs: number; readonly utilization: number; readonly usage: Pick< @@ -34,28 +35,42 @@ export interface EventLoopReadings { | "involuntaryContextSwitches" >; readonly rssBytes: number; + readonly steady?: boolean; } // Node skips the first gap after a reset, so a stall right at a sample boundary can be missed. const makeNodeSampler = Effect.gen(function* () { - const histogram = yield* Effect.acquireRelease( - Effect.sync(() => { - const histogram = NodePerfHooks.monitorEventLoopDelay({ resolution: RESOLUTION_MS }); - histogram.enable(); - return histogram; - }), - (histogram) => Effect.sync(() => histogram.disable()), - ); + const monitor = (resolution: number) => { + const histogram = NodePerfHooks.monitorEventLoopDelay({ resolution }); + histogram.enable(); + return histogram; + }; + let histogram = monitor(STARTUP.resolutionMs); + yield* Effect.addFinalizer(() => Effect.sync(() => histogram.disable())); + let samplesRead = 0; let elu = NodePerfHooks.performance.eventLoopUtilization(); let usage = process.resourceUsage(); + let sampledAt = NodePerfHooks.performance.now(); + // Cover stalls before the histogram's first wakeup. + const armedAt = sampledAt; + let startupDelayMs = 0; + // @effect-diagnostics-next-line globalTimersInEffect:off -- The native boot probe must arm before Effect scheduling can run startup work. + const startupProbe = setTimeout(() => { + startupDelayMs = Math.max(0, NodePerfHooks.performance.now() - armedAt - STARTUP.resolutionMs); + }, STARTUP.resolutionMs); + yield* Effect.addFinalizer(() => Effect.sync(() => clearTimeout(startupProbe))); // @effect-diagnostics-next-line returnEffectInGen:off - the read effect is the result. return Effect.sync(() => { const nextElu = NodePerfHooks.performance.eventLoopUtilization(); const nextUsage = process.resourceUsage(); + const now = NodePerfHooks.performance.now(); const loop = NodePerfHooks.performance.eventLoopUtilization(nextElu, elu); + const steady = samplesRead >= STARTUP_SAMPLES; + const intervalMs = (steady ? STEADY : STARTUP).sampleIntervalMs; const readings: EventLoopReadings = { delayMaxNs: histogram.max, + tickDelayMs: Math.max(startupDelayMs, now - sampledAt - intervalMs, 0), activeMs: loop.active, utilization: loop.utilization, usage: { @@ -67,19 +82,40 @@ const makeNodeSampler = Effect.gen(function* () { nextUsage.involuntaryContextSwitches - usage.involuntaryContextSwitches, }, rssBytes: process.memoryUsage.rss(), + ...(steady ? { steady } : {}), }; - histogram.reset(); + samplesRead += 1; + if (samplesRead === STARTUP_SAMPLES) { + histogram.disable(); + histogram = monitor(STEADY.resolutionMs); + } else { + histogram.reset(); + } + startupDelayMs = 0; elu = nextElu; usage = nextUsage; + sampledAt = now; return readings; }); }); -export const stallMs = ({ delayMaxNs, activeMs }: EventLoopReadings) => { - const delayMs = Math.round(delayMaxNs / 1e6) - RESOLUTION_MS; - // libuv's clock keeps running while the system sleeps on macOS and Windows, so a - // sleep also reads as delay, but the loop spent it idle in poll. - if (delayMs <= STALL_THRESHOLD_MS || activeMs < delayMs) return undefined; +/** + * Returns the stall to report for one sample in ms, or undefined when there was none. + */ +export const stallMs = ({ + delayMaxNs, + tickDelayMs = 0, + activeMs, + usage, + steady, +}: EventLoopReadings) => { + const { resolutionMs, stallThresholdMs } = steady ? STEADY : STARTUP; + const delayMs = Math.round(Math.max(delayMaxNs / 1e6 - resolutionMs, tickDelayMs)); + if (delayMs <= stallThresholdMs) return undefined; + // System sleep produces an idle gap. CPU time covers startup before ELU counts active time; + // short stalls bypass this check because those readings can lag. + const busyMs = Math.max(activeMs, (usage.userCPUTime + usage.systemCPUTime) / 1000); + if (delayMs > SLEEP_CHECK_MS && busyMs < delayMs / 2) return undefined; return delayMs; }; @@ -142,16 +178,14 @@ export const layerWith = ( ); } }); - const wait = Effect.sleep(SAMPLE_INTERVAL); - // The layer builds before the rest of the server, so the first sample covers - // startup work such as migrations and projection bootstrap. That can block the - // loop for seconds on a large database, so skip it rather than warn at every - // launch. - yield* wait.pipe( - Effect.andThen(sample), - Effect.andThen(wait.pipe(Effect.andThen(tick), Effect.forever)), - Effect.forkScoped, - ); + const sampleAfter = (intervalMs: number) => + Effect.sleep(intervalMs).pipe(Effect.andThen(tick)); + yield* Effect.gen(function* () { + for (let sample = 0; sample < STARTUP_SAMPLES; sample++) { + yield* sampleAfter(STARTUP.sampleIntervalMs); + } + return yield* sampleAfter(STEADY.sampleIntervalMs).pipe(Effect.forever); + }).pipe(Effect.forkScoped); }), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 8a06dddeeec10..3f9a337df676b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams +import * as ClaudeAgentSdkQueryRunnerService from "../../provider/ClaudeAgentSdkQueryRunner.ts"; import * as NodeEvents from "node:events"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; @@ -1180,7 +1181,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio prefix: "t3-claude-binary-path-", }), ), - Effect.provideService(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, { + Effect.provideService(ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner, { allocateSessionId: Effect.succeed("native-thread-claude-binary-path"), open: (input) => Effect.sync(() => { @@ -2083,7 +2084,7 @@ describe("ClaudeAdapterV2 MCP credential channel", () => { }; const openWith = (cli: ReturnType) => Effect.gen(function* () { - const runner = yield* ClaudeAdapterV2.ClaudeAgentSdkQueryRunner; + const runner = yield* ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner; return yield* runner.open({ threadId: ThreadId.make("thread-claude-mcp-channel"), providerSessionId: ProviderSessionId.make("provider-session-claude-mcp-channel"), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index ec3ec5b052461..8248378b4c4d5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -1,3 +1,4 @@ +import * as ClaudeAgentSdkQueryRunnerService from "../../provider/ClaudeAgentSdkQueryRunner.ts"; import { forkSession, query, @@ -942,13 +943,13 @@ const makeClaudeAgentSdkReplayQueryRunner = Effect.fn("ClaudeAgentSdkReplayQuery function replayQueryRunnerService( transcript: ClaudeAgentSdkReplayTranscript, queryRunner: ClaudeQueryRunner, -): ClaudeAdapterV2.ClaudeAgentSdkQueryRunner["Service"] { +): ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner["Service"] { const replay = (run: () => A) => Effect.try({ try: run, catch: (cause) => replayQueryRunnerError(transcript, cause), }); - return ClaudeAdapterV2.ClaudeAgentSdkQueryRunner.of({ + return ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner.of({ allocateSessionId: Effect.succeed(nativeSessionIdFor(transcript)), open: (input) => replay(() => queryRunner.open(input)), forkSession: (input) => replay(() => queryRunner.forkSession(input)), @@ -960,9 +961,9 @@ function replayQueryRunnerService( function layerClaudeAgentSdkReplayQueryRunner( transcript: ClaudeAgentSdkReplayTranscript, options: { readonly replayGate?: ProviderReplayGate } = {}, -): Layer.Layer { +): Layer.Layer { return Layer.effect( - ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, + ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner, makeClaudeAgentSdkReplayQueryRunner(transcript, options), ); } @@ -973,16 +974,16 @@ function layerClaudeAgentSdkReplay( readonly replayGate?: ProviderReplayGate; readonly queryRunner?: ClaudeQueryRunner; } = {}, -): Layer.Layer { +): Layer.Layer { if (options.queryRunner !== undefined) { return Layer.succeed( - ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, + ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner, replayQueryRunnerService(transcript, options.queryRunner), ); } const queryRunner = makeReplayQueryRunner(transcript, options); return Layer.effect( - ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, + ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner, Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index be276af5f0edf..781d8bf46758c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,3 +1,4 @@ +import { ClaudeAgentSdkQueryRunner } from "../../provider/ClaudeAgentSdkQueryRunner.ts"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; import { claudeTaskOutputRoot, tailClaudeTaskOutput } from "./ClaudeTaskOutputTail.ts"; import { @@ -25,7 +26,6 @@ import { forkSession as forkClaudeSession, type EffortLevel, type ForkSessionOptions, - type ForkSessionResult, getSubagentMessages, query, type Options as ClaudeQueryOptions, @@ -93,7 +93,6 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -485,25 +484,6 @@ export class ClaudeBackgroundWorkBlocksQueryReplacementError extends Schema.Tagg } } -export interface ClaudeAgentSdkQueryRunnerShape { - readonly allocateSessionId: Effect.Effect; - readonly open: ( - input: ClaudeAgentSdkQueryOpenInput, - ) => Effect.Effect; - readonly forkSession: ( - input: ClaudeAgentSdkSessionForkInput, - ) => Effect.Effect; - readonly subagentLaunchToolUseId: ( - input: ClaudeAgentSdkSubagentLookupInput, - ) => Effect.Effect; - readonly assertComplete: Effect.Effect; -} - -export class ClaudeAgentSdkQueryRunner extends Context.Service< - ClaudeAgentSdkQueryRunner, - ClaudeAgentSdkQueryRunnerShape ->()("t3/orchestration-v2/Adapters/ClaudeAdapterV2/ClaudeAgentSdkQueryRunner") {} - export interface ClaudeAgentSdkSessionForkInput { readonly sessionId: string; readonly options: ForkSessionOptions; @@ -3425,7 +3405,7 @@ export interface ClaudeAdapterV2Options { readonly path: Path.Path; readonly crypto: Crypto.Crypto; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; + readonly queryRunner: ClaudeAgentSdkQueryRunner["Service"]; readonly scopedLimitNames?: Ref.Ref; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; readonly continuationRequests?: { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index a691d7e8459af..2ad546db7a1a7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -14,11 +14,8 @@ import { vi } from "vite-plus/test"; import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { - CLAUDE_AGENT_SDK_QUERY_PROTOCOL, - ClaudeAgentSdkQueryRunner, - layerQueryRunner, -} from "./ClaudeAdapterV2.ts"; +import { ClaudeAgentSdkQueryRunner } from "../../provider/ClaudeAgentSdkQueryRunner.ts"; +import { CLAUDE_AGENT_SDK_QUERY_PROTOCOL, layerQueryRunner } from "./ClaudeAdapterV2.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 5d7f87494d5c5..4d916a2b1141f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1,3 +1,4 @@ +import * as CodexFactory from "../../provider/CodexAppServerClientFactory.ts"; import * as NodeOS from "node:os"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; @@ -770,7 +771,7 @@ describe("CodexAdapterV2 process spawning", () => { PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn" }), ); }); - const factory = yield* CodexAdapterV2.CodexAppServerClientFactory.pipe( + const factory = yield* CodexFactory.CodexAppServerClientFactory.pipe( Effect.provide(CodexAdapterV2.layerAppServerClientFactory), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService( @@ -1993,7 +1994,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { const serverConfig = yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie); const continuationRequests: Array = []; - const clientFactory: CodexAdapterV2.CodexAppServerClientFactoryShape = { + const clientFactory: CodexFactory.CodexAppServerClientFactory["Service"] = { open: (openInput) => Layer.build(CodexReplay.layerReplay(transcript)).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts index ea949828076da..3299648cc0d14 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts @@ -1,3 +1,4 @@ +import * as CodexFactory from "../../provider/CodexAppServerClientFactory.ts"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -189,7 +190,7 @@ export function layer(input: { input.driver === undefined ? CodexReplay.layerReplay(input.transcript) : CodexReplay.layerReplayWithDriver(input.driver); - const layerReplayClientFactory = Layer.succeed(CodexAdapterV2.CodexAppServerClientFactory, { + const layerReplayClientFactory = Layer.succeed(CodexFactory.CodexAppServerClientFactory, { open: (openInput) => Effect.gen(function* () { const context = yield* Layer.build(layerReplay).pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 928ae4a7e13a5..d791637d1d26c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,3 +1,4 @@ +import { CodexAppServerClientFactory } from "../../provider/CodexAppServerClientFactory.ts"; import { revertCodexThread } from "../../provider/CodexThreadRevert.ts"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; @@ -74,7 +75,6 @@ import * as CodexClient from "effect-codex-app-server/client"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexSchema from "effect-codex-app-server/schema"; import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -1262,26 +1262,6 @@ type CodexSubAgentActivityItem = Extract< { readonly type: "subAgentActivity" } >; -export interface CodexAppServerClientFactoryShape { - readonly open: (input: { - readonly instanceId: ProviderInstanceId; - readonly threadId: ThreadId; - readonly providerSessionId: OrchestrationV2ProviderSession["id"]; - readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; - readonly settings: CodexSettings; - readonly environment: NodeJS.ProcessEnv; - }) => Effect.Effect< - CodexClient.CodexAppServerClient["Service"], - ProviderAdapter.ProviderAdapterOpenSessionError, - Scope.Scope - >; -} - -export class CodexAppServerClientFactory extends Context.Service< - CodexAppServerClientFactory, - CodexAppServerClientFactoryShape ->()("t3/orchestration-v2/Adapters/CodexAdapterV2/CodexAppServerClientFactory") {} - /** * Codex waits this long for MCP servers before the first model request and * leaves out any server still starting (default 1 s, too short for cold @@ -1656,7 +1636,7 @@ export interface CodexAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly settings: CodexSettings; readonly environment: NodeJS.ProcessEnv; - readonly clientFactory: CodexAppServerClientFactoryShape; + readonly clientFactory: CodexAppServerClientFactory["Service"]; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; readonly resolveRuntime?: Effect.Effect; readonly crypto: Crypto.Crypto; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index 08e4fc9066ab1..9352541ab8013 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -404,7 +404,7 @@ function layerOpenCodeReplayRuntime(transcript: OpenCodeSdkReplayTranscript) { detail: "OpenCode replay does not execute commands.", }), ), - createOpenCodeSdkClient: () => client, + createOpenCodeSdkClient: () => Effect.succeed(client), loadOpenCodeInventory: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 7a0a5468ba99e..69c29653953b2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -374,6 +374,7 @@ export interface OrchestratorV2Shape { >; readonly getThreadShell: ( threadId: ThreadId, + options?: ShellSnapshotOptions, ) => Effect.Effect; readonly getThreadEventSequence: ( threadId: ThreadId, @@ -763,6 +764,25 @@ export function shouldPrepareLegacyImportHandoff(input: { ); } +/** + * The last change automatic settlement decides from: activity, or a pull request host sync, + * which records its time on the link instead of bumping the thread's activity timestamp. + */ +export function autoSettleSnapshotAt(thread: { + readonly updatedAt: DateTime.Utc; + readonly pullRequests?: + | ReadonlyArray<{ readonly snapshot: { readonly syncedAt: string } | null }> + | undefined; +}): DateTime.Utc { + return (thread.pullRequests ?? []).reduce( + (latest, link) => + link.snapshot === null + ? latest + : DateTime.max(latest, DateTime.makeUnsafe(link.snapshot.syncedAt)), + thread.updatedAt, + ); +} + export function appendContextHandoffId( handoffIds: OrchestrationV2ProviderThread["handoffIds"], handoffId: OrchestrationV2ContextHandoff["id"] | null, @@ -10890,7 +10910,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if ( thread.settledOverride !== null || - DateTime.toEpochMillis(thread.updatedAt) > DateTime.toEpochMillis(command.snapshotAt) + DateTime.toEpochMillis(autoSettleSnapshotAt(thread)) > + DateTime.toEpochMillis(command.snapshotAt) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -11652,9 +11673,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.mapError(shellProjectionError), Effect.map(Effect.mapError(shellProjectionError)), ), - getThreadShell: (threadId) => + getThreadShell: (threadId, options) => projectionStore - .getThreadShell(threadId) + .getThreadShell(threadId, options) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), getThreadEventSequence: (threadId) => eventSink diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index 3cbbe183cce3f..cb18e1e871b8a 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -132,6 +132,7 @@ export const layer: Layer.Layer< yield* sql`DELETE FROM orchestration_v2_projection_nodes`; yield* sql`DELETE FROM orchestration_v2_projection_run_attempts`; yield* sql`DELETE FROM orchestration_v2_projection_runs`; + yield* sql`DELETE FROM orchestration_v2_projection_thread_reads`; yield* sql`DELETE FROM orchestration_v2_projection_threads`; yield* sql`DELETE FROM orchestration_v2_turn_item_positions`; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 97573037d646e..6040891d9844f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -37,6 +37,8 @@ import ProjectionThreadSweepIndexes from "../persistence/Migrations/065_Projecti import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import { resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; +import { compactThreadPullRequests } from "./ThreadReadPayload.ts"; import { buildBoundedThreadProjection, decodeThreadHistoryCursor, @@ -2616,11 +2618,11 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ]); const [stored] = yield* sql<{ readonly payload_json: string }>` - SELECT payload_json FROM orchestration_v2_projection_threads + SELECT payload_json FROM orchestration_v2_projection_thread_reads WHERE thread_id = ${laterThreadId} `; const setPayload = (payload: string) => - sql`UPDATE orchestration_v2_projection_threads SET payload_json = ${payload} + sql`UPDATE orchestration_v2_projection_thread_reads SET payload_json = ${payload} WHERE thread_id = ${laterThreadId}`; yield* setPayload("{}"); const failure = yield* sql @@ -5996,6 +5998,68 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.deepEqual(synced.pullRequests?.[0]?.snapshot, snapshot); assert.isDefined(synced.pullRequests?.[0]?.watch); assert.deepEqual(synced.updatedAt, at); + const stack = { + kind: "native" as const, + id: "stack:codec", + number: 1, + url: "https://github.com/pingdotgg/t3code/stack/1", + base: "main", + // The thread links 7-9; 6 and 10 are stack layers it does not link. + layers: [6, 7, 8, 9, 10].map((number) => ({ + number, + headBranch: `branch-${number}`, + state: "open" as const, + })), + }; + const rich = { + ...synced, + pullRequests: [7, 8, 9].map((number) => ({ + ...synced.pullRequests![0]!, + number, + stack: number === 9 ? { ...stack, base: "older-base" } : stack, + snapshot: { ...snapshot, additions: 23, deletions: 2, checksState: "passing" as const }, + })), + }; + const compact = compactThreadPullRequests(rich); + // Clients show the saved stack before it refreshes, so the compact shell keeps it whole. + assert.deepEqual( + compact.pullRequests?.map((link) => link.stack), + rich.pullRequests.map((link) => link.stack), + ); + yield* syncPullRequests("rich", rich.pullRequests); + assert.deepEqual((yield* store.getThreadShell(threadId))?.pullRequests, rich.pullRequests); + assert.deepEqual( + (yield* store.getThreadShell(threadId, { compactPullRequests: true }))?.pullRequests, + compact.pullRequests, + ); + assert.deepEqual((yield* store.getThread(threadId)).pullRequests, rich.pullRequests); + const terminalAt = DateTime.formatIso(DateTime.add(at, { seconds: 1 })); + for (const state of ["merged", "closed"] as const) { + yield* syncPullRequests(`terminal-${state}`, [ + { + ...rich.pullRequests[0]!, + watch: undefined, + snapshot: { + ...snapshot, + state, + ...(state === "merged" ? { mergedAt: terminalAt } : { closedAt: terminalAt }), + }, + }, + ]); + const candidate = (yield* store.getSettlementCandidates(threadId))[0]; + assert.isDefined(candidate); + assert.deepEqual( + resolveAutoSettlementAt({ + thread: candidate!, + pullRequest: null, + nowMs: DateTime.toEpochMillis(DateTime.add(at, { seconds: 2 })), + autoSettleAfterDays: null, + autoSettleOnMerge: true, + }), + candidate!.latestRunCompletedAt, + ); + } + yield* syncPullRequests("restore", synced.pullRequests ?? []); assert.equal(yield* phase, "running"); assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); @@ -6007,6 +6071,11 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { SET payload_json = json_set(payload_json, '$.pullRequests', json('[]')) WHERE thread_id = ${threadId}`; assert.deepEqual(yield* store.getThreadsWithPullRequests(), []); + // Its write marks the read projection stale, so shells read the payload it wrote. + assert.deepEqual( + (yield* store.getThreadShell(threadId, { compactPullRequests: true }))?.pullRequests, + [], + ); yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = ${stored!.payload_json} WHERE thread_id = ${threadId}`; assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 67669ea65f355..dad8ba98cbe9c 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,7 +1,11 @@ import { applyThreadPullRequestLinkUpdate } from "@t3tools/shared/threadPullRequests"; import { projectTurnItemForWire } from "./WireProjection.ts"; +import { + compactThreadPullRequests, + decodeThreadReadPayload, + encodeThreadReadPayload, +} from "./ThreadReadPayload.ts"; import * as Stream from "effect/Stream"; -import { makeThreadFind, findProjectedThreadItems } from "./ThreadFind.ts"; import type { OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, @@ -75,7 +79,9 @@ import { import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Equal from "effect/Equal"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -353,6 +359,11 @@ export interface ProjectionTimelinePage { export interface ShellSnapshotOptions { readonly location?: "active" | "archive"; + readonly compactPullRequests?: boolean; + /** + * Reads only this project's threads. Fork sources in other projects still + * load, so visible item counts match the unscoped snapshot. + */ readonly projectId?: ProjectId; readonly unsettledOnly?: boolean; } @@ -410,6 +421,7 @@ export interface ProjectionStoreV2Shape { >; readonly getThreadShell: ( threadId: ThreadId, + options?: ShellSnapshotOptions, ) => Effect.Effect; readonly getThread: ( threadId: ThreadId, @@ -1018,9 +1030,16 @@ type ShellPendingTurnItemRow = PayloadRow & { readonly owning_provider_instance_id: string | null; }; -type ShellThreadRow = { +/** + * `payload_json` is the thread read projection when `read_payload` is 1. Otherwise the read row + * is missing or stale (another build rewrote the thread) and it is the thread payload itself. + */ +type ThreadReadRow = PayloadRow & { + readonly read_payload: number; +}; + +type ShellThreadRow = ThreadReadRow & { readonly thread_id: string; - readonly payload_json: string; readonly forked_from_run_source_thread_id: string | null; readonly latest_run_id: string | null; readonly latest_run_status: string | null; @@ -1056,6 +1075,7 @@ type SettlementThreadRow = Pick< ShellThreadRow, | "thread_id" | "payload_json" + | "read_payload" | "latest_run_id" | "latest_run_status" | "latest_run_requested_at" @@ -1139,6 +1159,12 @@ const encodeContextTransferPayload = Schema.encodeEffect( const decodeThreadPayload = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2AppThreadJsonSchema), ); +const decodeThreadReadRow = (row: ThreadReadRow, compactPullRequests: boolean) => + row.read_payload === 1 + ? decodeThreadReadPayload(row.payload_json) + : decodeThreadPayload(row.payload_json).pipe( + Effect.map((thread) => (compactPullRequests ? compactThreadPullRequests(thread) : thread)), + ); const decodeRunPayload = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2RunJsonSchema), ); @@ -1847,6 +1873,19 @@ export const layer: Layer.Layer = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const upsertThreadRead = (thread: OrchestrationV2AppThread) => + Effect.gen(function* () { + const payload = yield* encodeThreadReadPayload(thread); + const compact = yield* encodeThreadReadPayload(compactThreadPullRequests(thread)); + yield* sql` + INSERT INTO orchestration_v2_projection_thread_reads (thread_id, payload_json, compact_payload_json) + VALUES (${thread.id}, ${payload}, ${compact}) + ON CONFLICT(thread_id) DO UPDATE SET payload_json = excluded.payload_json, + compact_payload_json = excluded.compact_payload_json, + source_stale = 0 + `; + }); + // For run upserts: a snapshot without `path` keeps the value another event recorded there. const keepRecordedRunField = (payload: Statement.Fragment, path: string) => sql` CASE @@ -1942,6 +1981,7 @@ export const layer: Layer.Layer = deleted_at = excluded.deleted_at, payload_json = excluded.payload_json `; + yield* upsertThreadRead(event.payload); break; } // These events carry one field rather than the thread, so they patch the stored row. @@ -1969,6 +2009,7 @@ export const layer: Layer.Layer = payload_json = ${payloadJson} WHERE thread_id = ${event.threadId} `; + yield* upsertThreadRead(updatedThread); break; } case "run.created": @@ -2331,6 +2372,7 @@ export const layer: Layer.Layer = payload_json = ${updatedThreadPayloadJson} WHERE thread_id = ${event.payload.appThreadId} `; + yield* upsertThreadRead(updatedThread); } } break; @@ -5095,9 +5137,22 @@ export const layer: Layer.Layer = ), ); - const { searchThread, searchThreadStream } = yield* makeThreadFind((threadId) => - readTimelineIndex(threadId, new Set()).pipe(Effect.map((index) => index.visible)), + // A failed load is not kept, so the next search retries it. + const threadFind = yield* Effect.cachedWithTTL( + Effect.promise(() => import("./ThreadFind.ts")).pipe( + Effect.flatMap(({ makeThreadFind }) => + makeThreadFind((threadId) => + readTimelineIndex(threadId, new Set()).pipe(Effect.map((index) => index.visible)), + ), + ), + Effect.provideService(SqlClient.SqlClient, sql), + ), + (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), ); + const searchThread: ProjectionStoreV2Shape["searchThread"] = (input) => + threadFind.pipe(Effect.flatMap((find) => find.searchThread(input))); + const searchThreadStream: ProjectionStoreV2Shape["searchThreadStream"] = (input) => + Stream.unwrap(threadFind.pipe(Effect.map((find) => find.searchThreadStream(input)))); const getThreadHistoryPage: ProjectionStoreV2Shape["getThreadHistoryPage"] = ( threadId, @@ -5322,12 +5377,21 @@ export const layer: Layer.Layer = // exactly, so a scan of active threads reads them from the index instead of each payload. const selectShellThreadRows = ( threadId?: ThreadId, - { location, projectId, unsettledOnly = false }: ShellSnapshotOptions = {}, + { + location, + projectId, + unsettledOnly = false, + compactPullRequests = false, + }: ShellSnapshotOptions = {}, ) => sql` SELECT t.thread_id, - t.payload_json, + CASE + WHEN summary.thread_id IS NULL THEN t.payload_json + ELSE ${compactPullRequests ? sql`summary.compact_payload_json` : sql`summary.payload_json`} + END AS payload_json, + summary.thread_id IS NOT NULL AS read_payload, CASE WHEN json_extract(t.payload_json, '$.forkedFrom.type') = 'run' THEN json_extract(t.payload_json, '$.forkedFrom.threadId') @@ -5470,6 +5534,8 @@ export const layer: Layer.Layer = AND i.run_id IS NULL ) AS runless_item_count FROM orchestration_v2_projection_threads t + LEFT JOIN orchestration_v2_projection_thread_reads summary + ON summary.thread_id = t.thread_id AND summary.source_stale = 0 -- The newest run not waiting in a held queue, matching latestUnheldRun. LEFT JOIN orchestration_v2_projection_runs presented ON presented.run_id = ( SELECT candidate.run_id @@ -5652,7 +5718,9 @@ export const layer: Layer.Layer = .withTransaction( Effect.gen(function* () { const rows = yield* sql` - SELECT t.thread_id, t.payload_json, + SELECT t.thread_id, + CASE WHEN summary.thread_id IS NULL THEN t.payload_json ELSE summary.compact_payload_json END AS payload_json, + summary.thread_id IS NOT NULL AS read_payload, r.run_id AS latest_run_id, r.status AS latest_run_status, r.requested_at AS latest_run_requested_at, @@ -5674,6 +5742,8 @@ export const layer: Layer.Layer = LIMIT 1 ) AS latest_user_authored_message_at FROM orchestration_v2_projection_threads t + LEFT JOIN orchestration_v2_projection_thread_reads summary + ON summary.thread_id = t.thread_id AND summary.source_stale = 0 LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = ( SELECT latest.run_id FROM orchestration_v2_projection_runs latest WHERE latest.thread_id = t.thread_id @@ -5706,7 +5776,7 @@ export const layer: Layer.Layer = }); return yield* Effect.forEach(rows, (row) => Effect.gen(function* () { - const thread = yield* decodeThreadPayload(row.payload_json); + const thread = yield* decodeThreadReadRow(row, true); const status = shellStatusFromStoredRunStatus(row.latest_run_status); const latestRunId = row.latest_run_id === null ? null : RunId.make(row.latest_run_id); @@ -5764,17 +5834,21 @@ export const layer: Layer.Layer = threadId, ) => Effect.gen(function* () { - const rows = yield* sql` - SELECT payload_json - FROM orchestration_v2_projection_threads - WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} - AND archived_at IS NULL + const rows = yield* sql` + SELECT + CASE WHEN summary.thread_id IS NULL THEN t.payload_json ELSE summary.payload_json END AS payload_json, + summary.thread_id IS NOT NULL AS read_payload + FROM orchestration_v2_projection_threads t + LEFT JOIN orchestration_v2_projection_thread_reads summary + ON summary.thread_id = t.thread_id AND summary.source_stale = 0 + WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`} + AND t.archived_at IS NULL -- Read from the sweep index (migration 065) rather than each payload. - AND json_array_length(payload_json, '$.pullRequests') > 0 - ORDER BY updated_at ASC, thread_id ASC + AND json_array_length(t.payload_json, '$.pullRequests') > 0 + ORDER BY t.updated_at ASC, t.thread_id ASC `; return yield* Effect.forEach(rows, (row) => - decodeThreadPayload(row.payload_json).pipe( + decodeThreadReadRow(row, false).pipe( Effect.map((thread): ProjectionThreadPullRequests => ({ id: thread.id, projectId: thread.projectId, @@ -5806,6 +5880,7 @@ export const layer: Layer.Layer = const shellThreadStateFromRow = (input: { readonly row: ShellThreadRow; + readonly compactPullRequests: boolean; readonly runOrdinalsByThreadId: ReadonlyMap>; readonly itemCountsByThreadId: ReadonlyMap>; readonly providerThreadsByThreadId: ReadonlyMap< @@ -5825,7 +5900,7 @@ export const layer: Layer.Layer = providerThreadsByThreadId, pendingTurnItemsByThreadId, } = input; - const thread = yield* decodeThreadPayload(row.payload_json); + const thread = yield* decodeThreadReadRow(row, input.compactPullRequests); const pendingRuntimeRequest = row.pending_request_payload_json !== null ? yield* decodeRuntimeRequestPayload(row.pending_request_payload_json) @@ -5966,7 +6041,9 @@ export const layer: Layer.Layer = while (pendingSourceIds.length > 0) { const sourceId = pendingSourceIds.pop(); if (sourceId === undefined || rowsByThreadId.has(sourceId)) continue; - const source = (yield* selectShellThreadRows(sourceId))[0]; + const source = (yield* selectShellThreadRows(sourceId, { + compactPullRequests: options?.compactPullRequests ?? false, + }))[0]; if (source === undefined) continue; rowsByThreadId.set(sourceId, source); if (source.forked_from_run_source_thread_id !== null) { @@ -6001,6 +6078,7 @@ export const layer: Layer.Layer = sequenceRows, providerThreadRows, pendingTurnItemRows, + compactPullRequests: options?.compactPullRequests ?? false, }; }); @@ -6012,6 +6090,7 @@ export const layer: Layer.Layer = sequenceRows, providerThreadRows, pendingTurnItemRows, + compactPullRequests, }: Effect.Success>) => Effect.gen(function* () { const { runOrdinalsByThreadId, itemCountsByThreadId } = runMapsByThreadId({ @@ -6023,6 +6102,7 @@ export const layer: Layer.Layer = const states = yield* Effect.forEach(threadRows, (row) => shellThreadStateFromRow({ row, + compactPullRequests, runOrdinalsByThreadId, itemCountsByThreadId, providerThreadsByThreadId, @@ -6062,7 +6142,10 @@ export const layer: Layer.Layer = .withTransaction(readShellSnapshotRows(options)) .pipe(Effect.mapError(shellSnapshotReadError), Effect.flatMap(decodeShellSnapshot)); - const getThreadShell: ProjectionStoreV2Shape["getThreadShell"] = (threadId) => + // Per-thread shell for the live shell streams: reads only the target thread + // plus its fork-source chain instead of materializing every thread. Returns + // null when the thread is deleted or unknown. + const getThreadShell: ProjectionStoreV2Shape["getThreadShell"] = (threadId, options) => sql .withTransaction( Effect.gen(function* () { @@ -6073,7 +6156,9 @@ export const layer: Layer.Layer = if (nextId === undefined || rowsByThreadId.has(nextId)) { continue; } - const rows = yield* selectShellThreadRows(nextId); + const rows = yield* selectShellThreadRows(nextId, { + compactPullRequests: options?.compactPullRequests ?? false, + }); const row = rows[0]; if (row === undefined) { continue; @@ -6110,6 +6195,7 @@ export const layer: Layer.Layer = const states = yield* Effect.forEach([...rowsByThreadId.values()], (row) => shellThreadStateFromRow({ row, + compactPullRequests: options?.compactPullRequests ?? false, runOrdinalsByThreadId, itemCountsByThreadId, providerThreadsByThreadId, @@ -6228,7 +6314,17 @@ export const layerMemory: Layer.Layer = Layer.effect( const shells = yield* Effect.forEach( selectedThreadIds.toSorted((left, right) => String(left).localeCompare(String(right))), (threadId) => - service.getThreadProjection(threadId).pipe(Effect.map(threadShellFromProjection)), + service + .getThreadProjection(threadId) + .pipe( + Effect.map((projection) => + threadShellFromProjection( + options?.compactPullRequests + ? { ...projection, thread: compactThreadPullRequests(projection.thread) } + : projection, + ), + ), + ), ); const visible = shells.filter((thread) => thread.deletedAt === null); return { @@ -6240,7 +6336,7 @@ export const layerMemory: Layer.Layer = Layer.effect( }), readShellSnapshot: (options) => service.getShellSnapshot(options).pipe(Effect.map(Effect.succeed)), - getThreadShell: (threadId) => + getThreadShell: (threadId, options) => Effect.gen(function* () { const existing = (yield* Ref.get(replayState)).projections; if (!existing.has(threadId)) { @@ -6248,7 +6344,15 @@ export const layerMemory: Layer.Layer = Layer.effect( } const shell = yield* service .getThreadProjection(threadId) - .pipe(Effect.map(threadShellFromProjection)); + .pipe( + Effect.map((projection) => + threadShellFromProjection( + options?.compactPullRequests + ? { ...projection, thread: compactThreadPullRequests(projection.thread) } + : projection, + ), + ), + ); return shell.deletedAt === null ? shell : null; }), getThread: (threadId) => @@ -6779,13 +6883,13 @@ export const layerMemory: Layer.Layer = Layer.effect( ), searchThreadStream: (input) => Stream.fromEffect(service.searchThread(input)), searchThread: (input) => - service - .getThreadSnapshot(input.threadId) - .pipe( - Effect.flatMap(({ projection, snapshotSequence }) => - projection.thread.deletedAt !== null - ? Effect.fail(new ProjectionStoreThreadNotFoundError({ threadId: input.threadId })) - : Effect.succeed( + service.getThreadSnapshot(input.threadId).pipe( + Effect.flatMap(({ projection, snapshotSequence }) => + projection.thread.deletedAt !== null + ? Effect.fail(new ProjectionStoreThreadNotFoundError({ threadId: input.threadId })) + : Effect.promise(() => import("./ThreadFind.ts")).pipe( + Effect.map(({ findProjectedThreadItems }) => + // This store has no project records, so only worktree paths resolve labels. findProjectedThreadItems( projection.visibleTurnItems, input, @@ -6793,8 +6897,9 @@ export const layerMemory: Layer.Layer = Layer.effect( projection.thread.worktreePath ?? undefined, ), ), - ), + ), ), + ), getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly) => service.getThreadSnapshot(threadId).pipe( Effect.flatMap(({ projection, snapshotSequence }) => diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index d83dd9578c279..f23771e4ab738 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -364,6 +364,7 @@ export interface ThreadManagementServiceShape { }) => Effect.Effect; readonly readShellSnapshot: (options?: { readonly location?: "active" | "archive"; + readonly compactPullRequests?: boolean; }) => Effect.Effect< Effect.Effect, Orchestrator.OrchestratorV2Error diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index e47191c3fbc02..d3f701b6f0121 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -338,7 +338,12 @@ describe("ThreadPullRequestServiceV2 reads", () => { const service = yield* ThreadPullRequestService.make; yield* service.start(); yield* Deferred.succeed(activation, undefined); - expect(yield* Queue.take(reads)).toEqual({ location: "active", unsettledOnly: false }); + // Startup backfill reads every active thread. + expect(yield* Queue.take(reads)).toEqual({ + location: "active", + compactPullRequests: true, + unsettledOnly: false, + }); yield* service.drain; yield* PubSub.publish(events, { type: "thread.metadata-updated", @@ -425,10 +430,19 @@ describe("ThreadPullRequestServiceV2 reads", () => { const service = yield* ThreadPullRequestService.make; yield* service.start(); yield* Deferred.succeed(activation, undefined); - expect(yield* Queue.take(reads)).toEqual({ location: "active", unsettledOnly: false }); + // Backfill finds the settled branch thread and must read it again. + expect(yield* Queue.take(reads)).toEqual({ + location: "active", + compactPullRequests: true, + unsettledOnly: false, + }); yield* service.drain; yield* TestClock.adjust("1 minute"); - expect(yield* Queue.take(reads)).toEqual({ location: "active", unsettledOnly: true }); + expect(yield* Queue.take(reads)).toEqual({ + location: "active", + compactPullRequests: true, + unsettledOnly: true, + }); yield* service.drain; }).pipe(Effect.provide(layerDependencies)); }), diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index c57505449581c..dd86b4d2a62fb 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -125,6 +125,7 @@ export const make = Effect.gen(function* () { ? orchestrator .getShellSnapshot({ location: "active", + compactPullRequests: true, unsettledOnly: !(backfill || pendingBackfill.size > 0), }) .pipe(NodeSqliteClient.readOnly) @@ -132,7 +133,9 @@ export const make = Effect.gen(function* () { // Read the sequence first. The thread is then at least this new, so a // sync guarded by the sequence is rejected rather than missing a change. const snapshotSequence = yield* orchestrator.getThreadEventSequence(threadId); - const thread = yield* orchestrator.getThreadShell(threadId); + const thread = yield* orchestrator.getThreadShell(threadId, { + compactPullRequests: true, + }); return { snapshotSequence, threads: thread === null ? [] : [thread] }; }); diff --git a/apps/server/src/orchestration-v2/ThreadReadPayload.ts b/apps/server/src/orchestration-v2/ThreadReadPayload.ts new file mode 100644 index 0000000000000..cef16fa9d505f --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadReadPayload.ts @@ -0,0 +1,89 @@ +import { + type OrchestrationV2AppThread, + OrchestrationV2AppThreadJson, + ThreadPullRequestStack, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +/** Keep badge, search and settlement fields; PR views read canonical snapshots. */ +export const compactThreadPullRequests = ( + thread: OrchestrationV2AppThread, +): OrchestrationV2AppThread => { + const links = thread.pullRequests; + if (links === undefined) return thread; + return { + ...thread, + pullRequests: links.map((link) => ({ + ...link, + snapshot: + link.snapshot === null + ? null + : { + state: link.snapshot.state, + title: link.snapshot.title, + headBranch: link.snapshot.headBranch, + baseBranch: link.snapshot.baseBranch, + isDraft: link.snapshot.isDraft, + updatedAt: link.snapshot.updatedAt, + syncedAt: link.snapshot.syncedAt, + ...(link.snapshot.mergedAt == null ? {} : { mergedAt: link.snapshot.mergedAt }), + ...(link.snapshot.closedAt == null ? {} : { closedAt: link.snapshot.closedAt }), + }, + })), + }; +}; + +// Store repeated stacks once without changing the public thread schema. +const ReadPayload = Schema.fromJsonString( + Schema.Struct({ + thread: OrchestrationV2AppThreadJson, + stacks: Schema.Array(ThreadPullRequestStack), + stackIndices: Schema.Array(Schema.NullOr(Schema.Number)), + }), +); + +const encode = Schema.encodeEffect(ReadPayload); +const decode = Schema.decodeUnknownEffect(ReadPayload); + +export const encodeThreadReadPayload = (thread: OrchestrationV2AppThread) => { + const stacks: Array = []; + const indices = new Map(); + const stackIndices: Array = []; + const pullRequests = thread.pullRequests?.map((link) => { + if (link.stack === null) { + stackIndices.push(null); + return link; + } + // Compare the whole stack, not its id: two links can have different sync vintages. + const key = JSON.stringify(link.stack); + let index = indices.get(key); + if (index === undefined) { + index = stacks.length; + stacks.push(link.stack); + indices.set(key, index); + } + stackIndices.push(index); + return { ...link, stack: null }; + }); + return encode({ + thread: { ...thread, ...(pullRequests === undefined ? {} : { pullRequests }) }, + stacks, + stackIndices, + }); +}; + +export const decodeThreadReadPayload = (payload: string) => + decode(payload).pipe( + Effect.map(({ thread, stacks, stackIndices }) => ({ + ...thread, + ...(thread.pullRequests === undefined + ? {} + : { + pullRequests: thread.pullRequests.map((link, index) => ({ + ...link, + stack: stackIndices[index] == null ? null : (stacks[stackIndices[index]!] ?? null), + })), + }), + })), + ); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 12af97ff159fb..75905ac7d41f5 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -355,7 +355,7 @@ export const make = Effect.gen(function* () { type: "thread.auto-settle", commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), threadId: thread.id, - snapshotAt: thread.updatedAt, + snapshotAt: Orchestrator.autoSettleSnapshotAt(thread), settledAt, }); return null; diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 4ef21edd3b3c7..bad0156b3ea58 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -3,7 +3,10 @@ import type { OrchestrationV2ThreadStreamItem, } from "@t3tools/contracts"; -import { omitLocalVisibleTurnItems } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; +import { + omitCheckpointItemFiles, + omitLocalVisibleTurnItems, +} from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import { buildBoundedThreadProjection, @@ -42,18 +45,27 @@ type ThreadSnapshotStreamItem = Extract< export function boundedSnapshotResponseFields(input: { readonly bounded: BoundedProjectionResult; readonly compactTurnItems: boolean; + readonly compactCheckpointItems?: boolean; }) { const { bounded } = input; const compactProjection = input.compactTurnItems ? omitLocalVisibleTurnItems(bounded.projection) : null; + // After the turn-item compaction, which matches rows to items by identity. + const compactCheckpoints = + input.compactCheckpointItems === true + ? omitCheckpointItemFiles(compactProjection ?? bounded.projection) + : null; return { - projection: compactProjection ?? bounded.projection, + projection: compactCheckpoints?.projection ?? compactProjection ?? bounded.projection, historyCursor: bounded.historyCursor, hasMoreHistory: bounded.hasMoreHistory, latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, payloadBudgetExceeded: bounded.payloadBudgetExceeded, ...(compactProjection === null ? {} : { turnItemsOmitLocalVisible: true as const }), + ...(compactCheckpoints === null + ? {} + : { checkpointFilesOmittedItemIds: compactCheckpoints.itemIds }), }; } @@ -61,6 +73,7 @@ export function buildBoundedThreadStreamSnapshot(input: { readonly snapshotSequence: number; readonly projection: OrchestrationV2ThreadProjection; readonly compactTurnItems?: boolean; + readonly compactCheckpointItems?: boolean; }): ThreadSnapshotStreamItem { const bounded = buildBoundedThreadProjection({ snapshotSequence: input.snapshotSequence, @@ -72,6 +85,7 @@ export function buildBoundedThreadStreamSnapshot(input: { ...boundedSnapshotResponseFields({ bounded, compactTurnItems: input.compactTurnItems === true, + compactCheckpointItems: input.compactCheckpointItems === true, }), }; } diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts index 3e128d7999ea4..0270d27a15ed3 100644 --- a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -2,6 +2,9 @@ import { assert, describe, it } from "@effect/vitest"; import { AuthOrchestrationReadScope, AuthSessionId, + CheckpointId, + CheckpointRef, + CheckpointScopeId, EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, EnvironmentHttpApi, @@ -203,6 +206,68 @@ function itemUpdated( }; } +const forkCheckpointId = CheckpointId.make("checkpoint:compact-transport:fork"); +const forkCheckpointItemId = TurnItemId.make(`turn-item:${FORK}:checkpoint`); +const checkpointFiles = [ + { path: "src/a.ts", kind: "modified", additions: 3, deletions: 1 }, + { path: "src/b.ts", kind: "added", additions: 10, deletions: 0 }, +]; + +// A captured checkpoint and its turn item, which repeats the checkpoint's files. +function forkCheckpoint(at: DateTime.Utc): ReadonlyArray { + const scopeId = CheckpointScopeId.make(`checkpoint-scope:${FORK}`); + const nodeId = NodeId.make(`node:${FORK}:checkpoint`); + return [ + { + id: EventId.make(`event:${FORK}:checkpoint-captured`), + type: "checkpoint.captured", + threadId: FORK, + nodeId, + occurredAt: at, + payload: { + id: forkCheckpointId, + threadId: FORK, + scopeId, + runId: null, + nodeId, + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: null, + ref: CheckpointRef.make("refs/t3/compact-transport/1"), + status: "ready", + files: checkpointFiles, + capturedAt: at, + }, + }, + { + id: EventId.make(`event:${FORK}:checkpoint-item`), + type: "turn-item.updated", + threadId: FORK, + occurredAt: at, + payload: { + id: forkCheckpointItemId, + threadId: FORK, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 5, + status: "completed", + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "checkpoint", + checkpointId: forkCheckpointId, + scopeId, + files: checkpointFiles, + }, + }, + ]; +} + const seed = Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const at = yield* DateTime.now; @@ -212,6 +277,7 @@ const seed = Effect.gen(function* () { ...[1, 2, 3].map((n) => itemUpdated(PARENT, parentRun, `parent-${n}`, n, at)), threadCreated(FORK, at, parentRun), ...[1, 2, 3, 4].map((n) => itemUpdated(FORK, null, `fork-${n}`, n, at)), + ...forkCheckpoint(at), threadCreated(LONG, at), runCreated(LONG, longRun, at), itemUpdated(LONG, longRun, "request", 1, at, "run_interrupt_request"), @@ -350,6 +416,20 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { const { projection: _legacyProjection, ...legacyRest } = legacy; assert.deepStrictEqual(compactRest, legacyRest); assert.deepStrictEqual(boundedSnapshotProjection(compact), legacy.projection); + + // Checkpoint items are compacted only when that opt-in is exact too. + assert.notInclude(compactBody, "checkpointFilesOmittedItemIds"); + const bothBody = await ( + await get(`${base}?compactTurnItems=1&compactCheckpointItems=1`) + ).text(); + const both = decodeBounded(bothBody); + assert.deepStrictEqual(boundedSnapshotProjection(both), legacy.projection); + if (threadId === FORK) { + assert.deepStrictEqual(both.checkpointFilesOmittedItemIds, [forkCheckpointItemId]); + assert.isBelow(bothBody.length, compactBody.length); + } else { + assert.isUndefined(both.checkpointFilesOmittedItemIds); + } return { legacy, compact }; }).pipe( Effect.tap(({ legacy }) => @@ -365,6 +445,7 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { "local", "local", "local", + "local", ]); } else { const ids = itemIds(legacy.projection); @@ -409,6 +490,22 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { }, { ...legacy.decoded, turnItemsOmitLocalVisible: undefined }, ); + + const compactCheckpoints = yield* firstSnapshot({ + threadId, + acceptBoundedSnapshot: true, + acceptCompactTurnItems: true, + acceptCompactCheckpointItems: true, + }); + assert.isUndefined(compact.decoded.checkpointFilesOmittedItemIds); + assert.deepStrictEqual( + compactCheckpoints.decoded.checkpointFilesOmittedItemIds, + threadId === FORK ? [forkCheckpointItemId] : undefined, + ); + assert.deepStrictEqual( + boundedSnapshotProjection(compactCheckpoints.decoded), + legacy.decoded.projection, + ); }), ); }); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index cc8e987880df8..545ca5ef98430 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -75,11 +75,16 @@ export const layer = HttpApiBuilder.group( ), ); - const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* () { + const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* ( + compactPullRequests: boolean, + ) { const base = buildActiveShellSnapshot( yield* loadShellSnapshotParts({ sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), + readThreads: threadManagement.readShellSnapshot({ + location: "active", + compactPullRequests, + }), listProjects: projectStore.listShells(), latestSequence: applicationEvents.latestApplicationSequence, }), @@ -154,7 +159,7 @@ export const layer = HttpApiBuilder.group( Effect.fn("environment.orchestration.shellSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - return yield* loadShellSnapshot().pipe( + return yield* loadShellSnapshot(args.query.compactPullRequests === "true").pipe( traceLocalHandlerWork, Effect.catch((cause) => failEnvironmentInternal("orchestration_snapshot_failed", cause), @@ -219,6 +224,7 @@ export const layer = HttpApiBuilder.group( ...boundedSnapshotResponseFields({ bounded, compactTurnItems: args.query.compactTurnItems === "1", + compactCheckpointItems: args.query.compactCheckpointItems === "1", }), }; }), diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 1b64acf6c7b38..27f024f397ee5 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2080,6 +2080,46 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { number === 3 ? [1, 2, 3] : [1, 2], ); } + const beforeSync = (yield* orchestrator.getThreadRecords(threadId, [])).thread; + const syncedSnapshot = { + state: "open" as const, + title: "Updated host title", + headBranch: "feature/pr-links", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-07-24T00:00:00.000Z", + syncedAt: "2026-07-24T00:00:00.000Z", + }; + yield* orchestrator.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make("branch-pr-sync"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 2, + snapshot: syncedSnapshot, + stack: null, + }); + const afterSync = (yield* orchestrator.getThreadRecords(threadId, [])).thread; + assert.deepEqual( + afterSync.pullRequests?.map((link) => link.number), + [1, 2, 3], + ); + assert.deepEqual( + afterSync.pullRequests?.find((link) => link.number === 2)?.snapshot, + syncedSnapshot, + ); + assert.deepEqual(afterSync.updatedAt, beforeSync.updatedAt); + // A sync is not activity, but a settlement decided from the snapshot before it is stale. + const autoSettleBeforeSync = yield* orchestrator + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("branch-pr-auto-settle-before-sync"), + threadId, + snapshotAt: Orchestrator.autoSettleSnapshotAt(beforeSync), + }) + .pipe(Effect.flip); + assert.instanceOf(autoSettleBeforeSync, Orchestrator.OrchestratorDispatchError); yield* orchestrator.dispatch({ type: "thread.pull-request.unlink", commandId: CommandId.make("branch-pr-unlink"), diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 21c8e7a308f54..a4b5da819ae0d 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -66,7 +66,7 @@ describe("runMigrations", () => { const exit = yield* Effect.exit(runMigrations()); assert.isTrue(Exit.isFailure(exit)); - for (const [id, name] of v2Migrations) { + for (const [id, name] of v2Migrations.filter(([id]) => id !== 64)) { assert.include(String(exit), `${id}_${name} (recorded: nothing)`); } assert.deepStrictEqual(yield* history, before); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 17d0114a648bc..56e78977e54c0 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -70,6 +70,10 @@ import Migration0064 from "./Migrations/064_CanonicalizeThreadPullRequestLinks.t import Migration0065 from "./Migrations/065_ProjectionThreadSweepIndexes.ts"; +import ensureThreadReadProjection, { + migrateThreadReadProjection, +} from "./Migrations/066_ProjectionThreadReadPayload.ts"; + export const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], [2, "OrchestrationCommandReceipts", Migration0002], @@ -137,6 +141,7 @@ export const migrationEntries = [ [63, "DropUnusedV1Projections", Migration0063], [64, "CanonicalizeThreadPullRequestLinks", Migration0064], [65, "ProjectionThreadSweepIndexes", Migration0065], + [66, "ProjectionThreadReadPayload", migrateThreadReadProjection], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); @@ -257,5 +262,9 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ "Migrations of this build never ran on this database: the migrator skips ids at or below its latest recorded migration.", ).pipe(Effect.annotateLogs({ skipped })); } + // ProjectionStore reads this table, so it must exist even when its migration was skipped. + if (toMigrationInclusive === undefined || toMigrationInclusive >= 66) { + yield* ensureThreadReadProjection; + } return executedMigrations; }); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 8503bf8f5e429..6b3fd930f27d3 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -16,7 +16,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 65 }, (_, index) => index + 1), + Array.from({ length: 66 }, (_, index) => index + 1), ); }), ); @@ -40,6 +40,7 @@ layer("055_OrchestrationV2", (it) => { [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], [65, "ProjectionThreadSweepIndexes"], + [66, "ProjectionThreadReadPayload"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -71,6 +72,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 63, name: "DropUnusedV1Projections" }, { migration_id: 64, name: "CanonicalizeThreadPullRequestLinks" }, { migration_id: 65, name: "ProjectionThreadSweepIndexes" }, + { migration_id: 66, name: "ProjectionThreadReadPayload" }, ]); const tables = yield* sql<{ readonly name: string }>` @@ -145,6 +147,47 @@ layer("055_OrchestrationV2", (it) => { }), ); + // Startup ensures the derived read projection even when migration 66 was skipped. + it.effect("creates the thread read projection when another build took its migration id", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 65 }); + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (66, 'ProjectionThreadReadPayload')`; + assert.deepStrictEqual(yield* runMigrations(), []); + assert.deepStrictEqual( + yield* sql` + SELECT name FROM sqlite_master + WHERE name LIKE 'orchestration_v2_projection_thread_reads%' + ORDER BY name + `, + [ + { name: "orchestration_v2_projection_thread_reads" }, + { name: "orchestration_v2_projection_thread_reads_stale_on_insert" }, + { name: "orchestration_v2_projection_thread_reads_stale_on_update" }, + ], + ); + // Restarting keeps the projection. + yield* sql`INSERT INTO orchestration_v2_projection_thread_reads + (thread_id, compact_payload_json, payload_json) VALUES ('early', '{}', '{}')`; + assert.deepStrictEqual(yield* runMigrations(), []); + assert.deepStrictEqual( + yield* sql`SELECT thread_id FROM orchestration_v2_projection_thread_reads`, + [{ thread_id: "early" }], + ); + // Rebuild read rows written by early builds at id 63. + yield* sql`DELETE FROM effect_sql_migrations WHERE migration_id >= 63`; + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (63, 'ProjectionThreadReadPayload')`; + assert.deepStrictEqual( + yield* runMigrations(), + migrationEntries.filter(([id]) => id > 63).map(([id, name]) => [id, name]), + ); + assert.deepStrictEqual( + yield* sql`SELECT thread_id FROM orchestration_v2_projection_thread_reads`, + [], + ); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), + ); + it.effect.each([false, true])("repairs sweep indexes with legacy id 62: %s", (legacy) => { const warnings: Array = []; const logger = Logger.make(({ fiber, logLevel }) => { diff --git a/apps/server/src/persistence/Migrations/066_ProjectionThreadReadPayload.ts b/apps/server/src/persistence/Migrations/066_ProjectionThreadReadPayload.ts new file mode 100644 index 0000000000000..0ec9025334270 --- /dev/null +++ b/apps/server/src/persistence/Migrations/066_ProjectionThreadReadPayload.ts @@ -0,0 +1,101 @@ +import { OrchestrationV2AppThreadJson } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import ProjectionThreadSweepIndexes from "./065_ProjectionThreadSweepIndexes.ts"; + +import { + compactThreadPullRequests, + encodeThreadReadPayload, +} from "../../orchestration-v2/ThreadReadPayload.ts"; + +// Startup also ensures this table when another build recorded migration 66 or above. +// Triggers invalidate read rows when a build that does not maintain them writes a thread. +const ensureInTransaction = (sql: SqlClient.SqlClient, rebuild: boolean) => + Effect.gen(function* () { + const columns = yield* sql<{ readonly name: string }>` + SELECT name FROM pragma_table_info('orchestration_v2_projection_thread_reads') + `; + const current = columns.some((column) => column.name === "source_stale"); + if (current && rebuild) { + yield* sql`DELETE FROM orchestration_v2_projection_thread_reads`; + } else if (!current) { + // Early builds lacked source_stale; the derived rows can be rebuilt. + yield* sql`DROP TABLE IF EXISTS orchestration_v2_projection_thread_reads`; + yield* sql` + CREATE TABLE orchestration_v2_projection_thread_reads ( + thread_id TEXT PRIMARY KEY NOT NULL, + source_stale INTEGER NOT NULL DEFAULT 0, + compact_payload_json TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + `; + } + if (!current || rebuild) { + const decode = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2AppThreadJson), + ); + let after = ""; + while (true) { + const rows = yield* sql<{ thread_id: string; payload_json: string }>` + SELECT thread_id, payload_json FROM orchestration_v2_projection_threads + WHERE thread_id > ${after} ORDER BY thread_id LIMIT 100 + `; + if (rows.length === 0) break; + for (const row of rows) { + // A payload this build cannot decode keeps no read row; readers use the thread payload. + const encoded = yield* decode(row.payload_json).pipe( + Effect.flatMap((thread) => + Effect.all([ + encodeThreadReadPayload(thread), + encodeThreadReadPayload(compactThreadPullRequests(thread)), + ]), + ), + Effect.option, + ); + if (Option.isNone(encoded)) continue; + const [payload, compact] = encoded.value; + yield* sql` + INSERT INTO orchestration_v2_projection_thread_reads (thread_id, payload_json, compact_payload_json) + VALUES (${row.thread_id}, ${payload}, ${compact}) + `; + } + after = rows[rows.length - 1]!.thread_id; + } + } + yield* sql` + CREATE TRIGGER IF NOT EXISTS orchestration_v2_projection_thread_reads_stale_on_insert + AFTER INSERT ON orchestration_v2_projection_threads + BEGIN + UPDATE orchestration_v2_projection_thread_reads SET source_stale = 1 + WHERE thread_id = NEW.thread_id; + END + `; + yield* sql` + CREATE TRIGGER IF NOT EXISTS orchestration_v2_projection_thread_reads_stale_on_update + AFTER UPDATE OF payload_json ON orchestration_v2_projection_threads + BEGIN + UPDATE orchestration_v2_projection_thread_reads SET source_stale = 1 + WHERE thread_id = NEW.thread_id; + END + `; + }); + +const ensureThreadReadProjection = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql.withTransaction(ensureInTransaction(sql, false)); +}); + +export default ensureThreadReadProjection; + +/** + * Migration 66 itself rebuilds every row. Early builds of this change ran it as 62, 63 or 64 and wrote + * compact rows in an older format that the triggers never mark stale. + */ +export const migrateThreadReadProjection = Effect.gen(function* () { + // An old wave-2 ledger at 63 skipped the sweep migration on upgrade. + yield* ProjectionThreadSweepIndexes; + const sql = yield* SqlClient.SqlClient; + yield* sql.withTransaction(ensureInTransaction(sql, true)); +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index bcd1cc2493535..4871a4bb6e35a 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -45,6 +45,7 @@ describe("V2 preview upgrade", () => { [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], [65, "ProjectionThreadSweepIndexes"], + [66, "ProjectionThreadReadPayload"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -133,6 +134,7 @@ describe("V2 preview upgrade", () => { [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], [65, "ProjectionThreadSweepIndexes"], + [66, "ProjectionThreadReadPayload"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 861fc063a86e8..5f718734794c3 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1797,3 +1797,20 @@ it.live("a desktop page that comes back reconnects without waiting for a viewer" }), ).pipe(Effect.provide(layer)), ); + +it.live("a desktop page attached at startup reconnects before anything loads the browser", () => + Effect.scoped( + Effect.gen(function* () { + const manager = yield* Manager.PreviewManager; + // A server tab the desktop restores after a restart. + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + desktopTabs.add(opened.tabId); + yield* Layer.build(ServerBrowser.layer); + while (desktopDetaches.listenerCount("attach") === 0) yield* Effect.yieldNow; + expect(desktopConnections).toHaveLength(0); + desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId: opened.tabId }); + while (desktopConnections.length === 0) yield* Effect.yieldNow; + expect(desktopConnections[0]?.endpoint).toBe(`ws://desktop/${opened.tabId}`); + }), + ).pipe(Effect.provide(dependencies)), +); diff --git a/apps/server/src/process/SpawnHost.ts b/apps/server/src/process/SpawnHost.ts new file mode 100644 index 0000000000000..0dfe009e53190 --- /dev/null +++ b/apps/server/src/process/SpawnHost.ts @@ -0,0 +1,392 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off - the host client drives a Node child process from its IPC callbacks, outside the Effect runtime. +// Linux forks copy the parent's page tables. Spawn from a small host so a large +// server heap cannot block the event loop; other platforms spawn directly. +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import type * as NodeNet from "node:net"; +import type * as NodeStream from "node:stream"; + +import * as NodeChildProcessSpawner from "@effect/platform-node-shared/NodeChildProcessSpawner"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; + +import type { SpawnHostError, SpawnHostEvent, SpawnHostSpawnRequest } from "./spawnHostProcess.ts"; + +const MAX_HOST_FAILURES = 3; +const HOST_READY_TIMEOUT_MS = 10_000; +const HOSTED_OPTION_KEYS: ReadonlySet = new Set([ + "cwd", + "env", + "stdio", + "detached", + "shell", + "windowsHide", +]); +const HOSTED_STDIO: ReadonlySet = new Set([undefined, null, "pipe", "ignore", "inherit"]); + +const canHost = (options: NodeChildProcess.SpawnOptions) => + Object.keys(options).every((key) => HOSTED_OPTION_KEYS.has(key)) && + Array.isArray(options.stdio) && + options.stdio.every((stdio) => HOSTED_STDIO.has(stdio)); + +const toSpawnError = (error: SpawnHostError) => + Object.assign(new Error(error.message), { + code: error.code, + errno: error.errno, + syscall: error.syscall, + path: error.path, + spawnargs: error.spawnargs, + }); + +const killGroupOrProcess = (pid: number, signal: NodeJS.Signals) => { + try { + process.kill(-pid, signal); + } catch { + try { + process.kill(pid, signal); + } catch { + // Already gone. + } + } +}; + +export interface SpawnHostFailure { + readonly failures: number; + readonly disabled: boolean; + readonly cause: unknown; +} + +/** A child started by the host, shaped like the `ChildProcess` the Effect spawner reads. */ +class HostedChildProcess + extends NodeEvents.EventEmitter + implements NodeChildProcessSpawner.SpawnedChildProcess +{ + pid: number | undefined = undefined; + stdin: NodeStream.Writable | null = null; + stdout: NodeStream.Readable | null = null; + stderr: NodeStream.Readable | null = null; + stdio: Array = []; + state: "queued" | "sent" | "running" | "exited" = "queued"; + referenced = true; + readonly request: SpawnHostSpawnRequest; + readonly #client: SpawnHostClient; + #pendingSignal: NodeJS.Signals | number | undefined; + #direct: NodeChildProcess.ChildProcess | undefined; + + constructor(client: SpawnHostClient, request: SpawnHostSpawnRequest) { + super(); + this.#client = client; + this.request = request; + } + + kill(signal: NodeJS.Signals | number = "SIGTERM"): boolean { + if (this.#direct) return this.#direct.kill(signal); + if (this.state === "exited") return false; + if (this.state === "queued") { + this.#client.settle(this); + return true; + } + if (this.pid === undefined) { + this.#pendingSignal = signal; + return true; + } + try { + process.kill(this.pid, signal); + return true; + } catch { + return false; + } + } + + ref(): void { + if (this.#direct) this.#direct.ref(); + else this.#client.setReferenced(this, true); + } + + unref(): void { + if (this.#direct) this.#direct.unref(); + else this.#client.setReferenced(this, false); + } + + attachStdio(fd: number, socket: NodeNet.Socket): void { + this.stdio[fd] = socket; + if (fd === 0) { + // Transferred stdin can reset after the sink removes its error listener. + socket.on("error", () => {}); + this.stdin = socket; + } else if (fd === 1) this.stdout = socket; + else if (fd === 2) this.stderr = socket; + } + + markSpawned(pid: number): void { + this.state = "running"; + this.pid = pid; + if (this.#pendingSignal !== undefined) this.kill(this.#pendingSignal); + this.emit("spawn"); + } + + markExited(code: number | null, signal: NodeJS.Signals | null): void { + this.#client.settle(this); + // Node destroys a child's stdin when it exits. + this.stdin?.destroy(); + this.emit("exit", code, signal); + // Match Node's flushStdio: drain unread output to EOF so its socket closes. + process.nextTick(() => { + for (const stream of this.stdio) { + if ( + stream && + "resume" in stream && + stream.readable && + stream.listenerCount("data") === 0 && + stream.listenerCount("readable") === 0 + ) { + stream.resume(); + } + } + }); + } + + markFailed(error: Error): void { + this.#client.settle(this); + this.emit("error", error); + } + + spawnDirectly(): void { + this.#client.settle(this); + const { command, args, options } = this.request; + const child = NodeChildProcess.spawn(command, [...args], options); + this.#direct = child; + this.stdin = child.stdin; + this.stdout = child.stdout; + this.stderr = child.stderr; + this.stdio = [...child.stdio]; + if (!this.referenced) child.unref(); + child.on("error", (error) => this.emit("error", error)); + child.on("spawn", () => { + this.pid = child.pid; + this.emit("spawn"); + }); + child.on("exit", (code, signal) => this.emit("exit", code, signal)); + } +} + +class SpawnHostClient { + readonly #command: string; + readonly #args: ReadonlyArray; + readonly #onHostFailure: (failure: SpawnHostFailure) => void; + readonly #children = new Map(); + #host: NodeChildProcess.ChildProcess | undefined; + #ready = false; + #closed = false; + #failures = 0; + #nextId = 0; + #referencedChildren = 0; + #readyTimer: NodeJS.Timeout | undefined; + + constructor( + command: string, + args: ReadonlyArray, + onHostFailure: (failure: SpawnHostFailure) => void, + ) { + this.#command = command; + this.#args = args; + this.#onHostFailure = onHostFailure; + } + + readonly spawn = ( + command: string, + args: ReadonlyArray, + options: NodeChildProcess.SpawnOptions, + ): NodeChildProcessSpawner.SpawnedChildProcess => { + if (this.#closed || this.#failures >= MAX_HOST_FAILURES || !canHost(options)) { + return NodeChildProcess.spawn(command, [...args], options); + } + const child = new HostedChildProcess(this, { + type: "spawn", + id: ++this.#nextId, + command, + args, + // The host's own cwd and env are a snapshot from its start; send this process's. + options: { ...options, cwd: options.cwd ?? process.cwd(), env: options.env ?? process.env }, + }); + this.#children.set(child.request.id, child); + this.#referencedChildren++; + if (this.#ready) this.#send(child); + else this.#start(); + this.#syncChannelRef(); + return child; + }; + + settle(child: HostedChildProcess): void { + if (child.state === "exited") return; + child.state = "exited"; + this.#children.delete(child.request.id); + if (child.referenced) this.#referencedChildren--; + this.#syncChannelRef(); + } + + setReferenced(child: HostedChildProcess, referenced: boolean): void { + if (child.referenced === referenced) return; + child.referenced = referenced; + if (child.state !== "exited") this.#referencedChildren += referenced ? 1 : -1; + this.#syncChannelRef(); + } + + close(): void { + this.#closed = true; + clearTimeout(this.#readyTimer); + const host = this.#host; + this.#host = undefined; + if (host?.connected) host.disconnect(); + } + + // Match a direct child's process handle: only referenced children keep the server alive. + #syncChannelRef(): void { + const channel = this.#host?.channel; + if (!channel) return; + if (this.#referencedChildren > 0) channel.ref(); + else channel.unref(); + } + + #send(child: HostedChildProcess): void { + const host = this.#host; + // After IPC EOF, leave it queued for the next host instead of sending to a closed channel. + if (!host?.connected) return; + child.state = "sent"; + host.send(child.request); + } + + #start(): void { + if (this.#host) return; + const env = { ...process.env }; + // The host runs only its own module; preloads and flags meant for the server stay out. + delete env.NODE_OPTIONS; + let host: NodeChildProcess.ChildProcess; + try { + host = NodeChildProcess.spawn(this.#command, [...this.#args], { + env, + stdio: ["inherit", "inherit", "inherit", "ipc"], + }); + } catch (cause) { + this.#failures++; + this.#onHostLost(cause); + return; + } + this.#host = host; + host.unref(); + this.#syncChannelRef(); + this.#readyTimer = setTimeout(() => { + host.kill("SIGKILL"); + this.#lose(host, new Error("The spawn host did not start in time.")); + }, HOST_READY_TIMEOUT_MS); + this.#readyTimer.unref(); + host.on("message", (event, socket) => { + if (host === this.#host) { + this.#onEvent(event as SpawnHostEvent, socket as NodeNet.Socket | undefined); + } + }); + // Keep listening: later errors would otherwise crash the server. + host.on("error", (cause) => this.#lose(host, cause)); + // `close` follows the last IPC message, so every exit the host reported is handled first. + host.once("close", (code, signal) => + this.#lose(host, new Error(`The spawn host exited (code ${code}, signal ${signal}).`)), + ); + } + + #onEvent(event: SpawnHostEvent, socket: NodeNet.Socket | undefined): void { + if (event.type === "ready") { + clearTimeout(this.#readyTimer); + this.#ready = true; + for (const child of this.#children.values()) { + if (child.state === "queued") this.#send(child); + } + return; + } + const child = this.#children.get(event.id); + if (!child) { + socket?.destroy(); + return; + } + switch (event.type) { + case "stdio": + if (socket) child.attachStdio(event.fd, socket); + return; + case "spawn": + child.markSpawned(event.pid); + return; + case "exit": + child.markExited(event.code, event.signal); + return; + case "error": + child.markFailed(toSpawnError(event.error)); + return; + } + } + + #lose(host: NodeChildProcess.ChildProcess, cause: unknown): void { + if (host !== this.#host) return; + clearTimeout(this.#readyTimer); + this.#host = undefined; + this.#ready = false; + if (host.connected) host.disconnect(); + if (this.#closed) return; + this.#failures++; + this.#onHostLost(cause); + } + + #onHostLost(cause: unknown): void { + const disabled = this.#failures >= MAX_HOST_FAILURES; + this.#onHostFailure({ failures: this.#failures, disabled, cause }); + for (const child of this.#children.values()) { + if (child.state === "queued") { + if (disabled) child.spawnDirectly(); + } else if (child.state === "sent") { + // It may have started; spawning again could run it twice. + child.markFailed(new Error("The spawn host exited before reporting the spawn.")); + } else if (!child.referenced) { + // Detached editors outlive the host, as they would a scope release. + child.markExited(null, null); + } else if (child.pid !== undefined) { + // Its exit status left with the host. Stop the group it leads, as a scope release would. + killGroupOrProcess(child.pid, "SIGKILL"); + child.markExited(null, "SIGKILL"); + } + } + if (this.#children.size > 0 && !disabled) this.#start(); + } +} + +export const layerHost = ( + command: string, + args: ReadonlyArray, + onHostFailure: (failure: SpawnHostFailure) => void, +) => + Layer.unwrap( + Effect.acquireRelease( + Effect.sync(() => new SpawnHostClient(command, args, onHostFailure)), + (client) => Effect.sync(() => client.close()), + ).pipe(Effect.map((client) => NodeChildProcessSpawner.layerWithSpawn(client.spawn))), + ); + +export const layer = Layer.unwrap( + Effect.gen(function* () { + if ((yield* HostProcess.Platform) !== "linux") return NodeChildProcessSpawner.layer; + const invocation = yield* resolveSelfInvocation(); + const runFork = Effect.runForkWith(yield* Effect.context()); + return layerHost( + invocation.command, + selfInvocationArgs(invocation, ["__spawn-host"]), + ({ failures, disabled, cause }) => + runFork( + Effect.logWarning( + disabled + ? "The spawn host failed; child processes now start from the server." + : "The spawn host stopped; it restarts for the next child process.", + { failures, cause }, + ), + ), + ); + }), +); diff --git a/apps/server/src/process/spawnHostProcess.ts b/apps/server/src/process/spawnHostProcess.ts new file mode 100644 index 0000000000000..6526feff821c3 --- /dev/null +++ b/apps/server/src/process/spawnHostProcess.ts @@ -0,0 +1,93 @@ +// @effect-diagnostics nodeBuiltinImport:off - the host runs without the Effect runtime to stay small. +// `t3 __spawn-host` must import only Node builtins to keep its heap small. +// Transfer stdio sockets over IPC so output bypasses the host. +import * as NodeChildProcess from "node:child_process"; +import type * as NodeNet from "node:net"; + +export interface SpawnHostSpawnRequest { + readonly type: "spawn"; + readonly id: number; + readonly command: string; + readonly args: ReadonlyArray; + readonly options: NodeChildProcess.SpawnOptions; +} + +export interface SpawnHostError { + readonly message: string; + readonly code?: string | undefined; + readonly errno?: number | undefined; + readonly syscall?: string | undefined; + readonly path?: string | undefined; + readonly spawnargs?: ReadonlyArray | undefined; +} + +/** Sent in order per child: `stdio` once per piped fd (carrying the socket), `spawn`, then `exit`. */ +export type SpawnHostEvent = + | { readonly type: "ready" } + | { readonly type: "stdio"; readonly id: number; readonly fd: number } + | { readonly type: "spawn"; readonly id: number; readonly pid: number } + | { + readonly type: "exit"; + readonly id: number; + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + } + | { readonly type: "error"; readonly id: number; readonly error: SpawnHostError }; + +const toSpawnHostError = (cause: unknown): SpawnHostError => { + const error = cause as Partial & { spawnargs?: ReadonlyArray }; + return { + message: typeof error?.message === "string" ? error.message : String(cause), + code: error?.code, + errno: error?.errno, + syscall: error?.syscall, + path: error?.path, + spawnargs: error?.spawnargs, + }; +}; + +// Child stdio has no public paused option; use net's pauseOnCreate mechanism. +const stopReading = (socket: NodeNet.Socket) => + (socket as unknown as { _handle?: { readStop?: () => void } | null })._handle?.readStop?.(); + +export function runSpawnHost(): void { + const channel = process.send?.bind(process); + if (!channel) { + process.stderr.write("t3 __spawn-host is started by the T3 server.\n"); + process.exitCode = 1; + return; + } + const send = (event: SpawnHostEvent, socket?: NodeNet.Socket) => channel(event, socket); + + // Keep reporting exits during group shutdown until the server closes IPC. + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, () => {}); + process.on("disconnect", () => process.exit(0)); + + process.on("message", (request: SpawnHostSpawnRequest) => { + if (request.type !== "spawn") return; + const { id } = request; + let child: NodeChildProcess.ChildProcess; + try { + child = NodeChildProcess.spawn(request.command, [...request.args], request.options); + } catch (cause) { + send({ type: "error", id, error: toSpawnHostError(cause) }); + return; + } + // Stop reads before the loop polls, preserving every byte for the server. + const stdio = child.stdio.map((socket) => { + if (socket) stopReading(socket as NodeNet.Socket); + return socket as NodeNet.Socket | null; + }); + // ChildProcess destroys stdin on exit, which can come before its socket leaves the send queue. + child.stdin = null; + child.on("error", (cause) => send({ type: "error", id, error: toSpawnHostError(cause) })); + child.once("spawn", () => { + stdio.forEach((socket, fd) => { + if (socket) send({ type: "stdio", id, fd }, socket); + }); + send({ type: "spawn", id, pid: child.pid! }); + }); + child.on("exit", (code, signal) => send({ type: "exit", id, code, signal })); + }); + send({ type: "ready" }); +} diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 13e811baedfd0..c8007a58d5116 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,3 +1,10 @@ +// @effect-diagnostics nodeBuiltinImport:off - the spawn host tests watch real child processes. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -8,10 +15,11 @@ import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import * as SpawnHost from "./process/SpawnHost.ts"; import * as ProcessRunner from "./processRunner.ts"; type ChildProcessCommand = { @@ -430,6 +438,152 @@ describe("runProcess", () => { ); }); +const hostPlatform = HostProcess.Platform.defaultValue(); + +describe.skipIf(hostPlatform === "win32")("runProcess through the spawn host", () => { + const hostFailures: Array = []; + const layer = ProcessRunner.layer.pipe( + Layer.provideMerge( + SpawnHost.layerHost( + process.execPath, + [NodeURL.fileURLToPath(new URL("./bin.ts", import.meta.url)), "__spawn-host"], + (failure) => hostFailures.push(failure), + ), + ), + Layer.provide(NodeServices.layer), + ); + const run = (input: ProcessRunner.ProcessRunInput) => + Effect.flatMap(Effect.service(ProcessRunner.ProcessRunner), (runner) => runner.run(input)); + const isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + /** Runs `sleep 30` that records its pid, so the test can find it. */ + const sleeper = (pidFile: string) => ({ + command: "sh", + args: ["-c", `echo $$ > '${pidFile}'; exec sleep 30`], + }); + let pidFiles = 0; + const tempPidFile = () => + NodePath.join(NodeOS.tmpdir(), `t3-spawn-host-${process.pid}-${++pidFiles}.pid`); + const readPid = (pidFile: string) => + Effect.gen(function* () { + while (!NodeFS.existsSync(pidFile)) yield* Effect.sleep("20 millis"); + yield* Effect.sleep("50 millis"); + const pid = Number(NodeFS.readFileSync(pidFile, "utf8")); + NodeFS.rmSync(pidFile); + return pid; + }); + /** `pid (comm) state ppid ...` */ + const parentPid = (pid: number) => + Number(NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8").split(") ")[1]?.split(" ")[1]); + /** This process's open sockets by inode, so a reused fd number is not taken for the old socket. */ + const openSockets = () => + new Set( + NodeFS.readdirSync("/proc/self/fd").flatMap((fd) => { + try { + const target = NodeFS.readlinkSync(`/proc/self/fd/${fd}`); + return target.startsWith("socket:") ? [target] : []; + } catch { + return []; + } + }), + ); + + it.live("streams stdin, stdout, stderr and the exit code", () => + Effect.gen(function* () { + const input = `${"x".repeat(2_000_000)}\n`; + const echoed = yield* run({ + command: "sh", + args: ["-c", "cat; echo done >&2; exit 3"], + stdin: input, + maxOutputBytes: 4_000_000, + }); + expect(echoed.stdout).toBe(input); + expect(echoed.stderr).toBe("done\n"); + expect(echoed.code).toBe(3); + }).pipe(Effect.provide(layer)), + ); + + it.live("survives unread stdin resets and still reports active write failures", () => + Effect.gen(function* () { + const child = { command: "sh", args: ["-c", "sleep .02; exit 0"] }; + const result = yield* run({ ...child, stdin: "x".repeat(1024) }); + expect(result.code).toBe(0); + + const error = yield* run({ ...child, stdin: "x".repeat(2_000_000) }).pipe(Effect.flip); + expect(error).toBeInstanceOf(ProcessRunner.ProcessStdinError); + }).pipe(Effect.provide(layer)), + ); + + it.live.skipIf(hostPlatform !== "linux")( + "closes the sockets of output nobody reads once the child exits", + () => + Effect.gen(function* () { + // Starts the host first, so its channel is not counted. + yield* run({ command: "true", args: [] }); + const before = openSockets(); + // The limit stops the reader with output still buffered in the socket. + const error = yield* run({ command: "yes", args: [], maxOutputBytes: 1_000 }).pipe( + Effect.flip, + ); + expect(error._tag).toBe("ProcessOutputLimitError"); + const leaked = () => [...openSockets()].filter((socket) => !before.has(socket)); + for (let attempt = 0; attempt < 100 && leaked().length > 0; attempt++) { + yield* Effect.sleep("20 millis"); + } + expect(leaked()).toEqual([]); + }).pipe(Effect.provide(layer)), + ); + + it.live.skipIf(hostPlatform !== "linux")( + "fails and stops a running child when the host dies, then starts a new host", + () => + Effect.gen(function* () { + // An unreferenced child, like an editor the launcher detaches, outlives the host. + const keptPidFile = tempPidFile(); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + yield* spawner + .spawn( + ChildProcess.make("sh", ["-c", `echo $$ > '${keptPidFile}'; exec sleep 30`], { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Effect.flatMap((handle) => handle.unref), + Effect.asVoid, + Effect.scoped, + ); + const keptPid = yield* readPid(keptPidFile); + const pidFile = tempPidFile(); + const running = yield* run(sleeper(pidFile)).pipe(Effect.flip, Effect.forkScoped); + const childPid = yield* readPid(pidFile); + // The children's parent is the host. + const hostPid = parentPid(childPid); + expect(hostPid).not.toBe(process.pid); + expect(parentPid(keptPid)).toBe(hostPid); + process.kill(hostPid, "SIGKILL"); + + const error = yield* Fiber.join(running); + expect(error._tag).toBe("ProcessReadError"); + expect(isAlive(childPid)).toBe(false); + expect(isAlive(keptPid)).toBe(true); + process.kill(-keptPid, "SIGKILL"); + expect(hostFailures.at(-1)).toMatchObject({ failures: 1, disabled: false }); + + const after = yield* run({ command: "echo", args: ["again"] }); + expect(after.stdout).toBe("again\n"); + }).pipe(Effect.provide(layer)), + ); +}); + describe("isWindowsCommandNotFound", () => { it.effect("matches the localized German cmd.exe error text", () => Effect.gen(function* () { diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 453cf4431bdc7..6e765c86945ea 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -1,3 +1,4 @@ +import * as ProjectLayers from "../orchestration-v2/runtimeLayer.ts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { @@ -20,7 +21,6 @@ import * as Scope from "effect/Scope"; import * as ServerConfig from "../config.ts"; import * as GitManager from "../git/GitManager.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; -import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -73,7 +73,7 @@ const layer = (baseDir: string, options?: HarnessOptions) => ProjectService.ProjectService.pipe(Effect.map(options.projects)), ), ), - Layer.provideMerge(RuntimeLayer.layerProjectService), + Layer.provideMerge(ProjectLayers.layerProjectService), Layer.provideMerge(layerEnrichment), Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)), diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 67e76bfeafbc8..4633fbc630dd6 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -1,3 +1,4 @@ +import * as ProjectLayers from "../orchestration-v2/runtimeLayer.ts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, type Project, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; @@ -16,7 +17,6 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; -import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -56,7 +56,7 @@ const layerTestFor = ( | RepositoryIdentityResolver.RepositoryIdentityResolver >, ) => - RuntimeLayer.layerProjectService.pipe( + ProjectLayers.layerProjectService.pipe( Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(layerWorkspacePaths), Layer.provideMerge(projectMetadataLayer), @@ -69,13 +69,15 @@ const layerTestFor = ( const layerTest = layerTestFor(layerMetadata); const layerProjectServiceDependencies = Layer.mergeAll( - RuntimeLayer.layerEventSink, + ProjectLayers.layerEventSink, ProjectStore.layer, ProjectionStore.layer, IdAllocator.layer, ThreadCommandExecutor.layer, ).pipe( - Layer.provideMerge(LegacyV1ThreadImporter.layer.pipe(Layer.provide(RuntimeLayer.layerEventSink))), + Layer.provideMerge( + LegacyV1ThreadImporter.layer.pipe(Layer.provide(ProjectLayers.layerEventSink)), + ), Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(layerWorkspacePaths), Layer.provideMerge(layerMetadata), diff --git a/apps/server/src/provider/ClaudeAgentSdkQueryRunner.ts b/apps/server/src/provider/ClaudeAgentSdkQueryRunner.ts new file mode 100644 index 0000000000000..16c34e340b3b4 --- /dev/null +++ b/apps/server/src/provider/ClaudeAgentSdkQueryRunner.ts @@ -0,0 +1,50 @@ +import { cacheInOwnerScope } from "@t3tools/shared/lazyService"; +import type { ForkSessionResult } from "@anthropic-ai/claude-agent-sdk"; +import type { + ClaudeAgentSdkQueryRunnerError, + ClaudeAgentSdkQueryOpenInput, + ClaudeAgentSdkQuerySession, + ClaudeAgentSdkSessionForkInput, + ClaudeAgentSdkSubagentLookupInput, +} from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export class ClaudeAgentSdkQueryRunner extends Context.Service< + ClaudeAgentSdkQueryRunner, + { + readonly allocateSessionId: Effect.Effect; + readonly open: ( + input: ClaudeAgentSdkQueryOpenInput, + ) => Effect.Effect; + readonly forkSession: ( + input: ClaudeAgentSdkSessionForkInput, + ) => Effect.Effect; + /** The tool call that launched a subagent, read from the CLI's session storage. */ + readonly subagentLaunchToolUseId: ( + input: ClaudeAgentSdkSubagentLookupInput, + ) => Effect.Effect; + readonly assertComplete: Effect.Effect; + } +>()("t3/provider/ClaudeAgentSdkQueryRunner") {} + +export const layer = Layer.effect( + ClaudeAgentSdkQueryRunner, + Effect.gen(function* () { + const service = yield* cacheInOwnerScope( + Effect.promise(() => import("../orchestration-v2/Adapters/ClaudeAdapterV2.ts")).pipe( + Effect.flatMap((adapter) => Layer.build(adapter.layerQueryRunner)), + Effect.map((services) => Context.get(services, ClaudeAgentSdkQueryRunner)), + ), + ); + return ClaudeAgentSdkQueryRunner.of({ + allocateSessionId: service.pipe(Effect.flatMap((value) => value.allocateSessionId)), + open: (input) => service.pipe(Effect.flatMap((value) => value.open(input))), + forkSession: (input) => service.pipe(Effect.flatMap((value) => value.forkSession(input))), + subagentLaunchToolUseId: (input) => + service.pipe(Effect.flatMap((value) => value.subagentLaunchToolUseId(input))), + assertComplete: service.pipe(Effect.flatMap((value) => value.assertComplete)), + }); + }), +); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index a30e58f177c34..e6df486f19c8e 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -16,13 +16,12 @@ import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { - query as claudeQuery, - type Options as ClaudeQueryOptions, - type SlashCommand as ClaudeSlashCommand, - type SDKControlGetUsageResponse, - type SDKUserMessage, - type SettingSource, +import type { + Options as ClaudeQueryOptions, + SlashCommand as ClaudeSlashCommand, + SDKControlGetUsageResponse, + SDKUserMessage, + SettingSource, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -358,6 +357,7 @@ const probeClaudeCapabilities = ( ); const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); return yield* Effect.tryPromise(async () => { + const { query: claudeQuery } = await import("@anthropic-ai/claude-agent-sdk"); const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. // This prevents any prompt from reaching the Anthropic API. diff --git a/apps/server/src/provider/CodexAppServerClientFactory.ts b/apps/server/src/provider/CodexAppServerClientFactory.ts new file mode 100644 index 0000000000000..d82f4dd7039f4 --- /dev/null +++ b/apps/server/src/provider/CodexAppServerClientFactory.ts @@ -0,0 +1,46 @@ +import { cacheInOwnerScope } from "@t3tools/shared/lazyService"; +import type { + ProviderInstanceId, + ThreadId, + OrchestrationV2ProviderSession, + CodexSettings, +} from "@t3tools/contracts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as CodexClient from "effect-codex-app-server/client"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +export class CodexAppServerClientFactory extends Context.Service< + CodexAppServerClientFactory, + { + readonly open: (input: { + readonly instanceId: ProviderInstanceId; + readonly threadId: ThreadId; + readonly providerSessionId: OrchestrationV2ProviderSession["id"]; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + readonly settings: CodexSettings; + readonly environment: NodeJS.ProcessEnv; + }) => Effect.Effect< + CodexClient.CodexAppServerClient["Service"], + ProviderAdapter.ProviderAdapterOpenSessionError, + Scope.Scope + >; + } +>()("t3/provider/CodexAppServerClientFactory") {} + +export const layer = Layer.effect( + CodexAppServerClientFactory, + Effect.gen(function* () { + const service = yield* cacheInOwnerScope( + Effect.promise(() => import("../orchestration-v2/Adapters/CodexAdapterV2.ts")).pipe( + Effect.flatMap((adapter) => Layer.build(adapter.layerAppServerClientFactory)), + Effect.map((services) => Context.get(services, CodexAppServerClientFactory)), + ), + ); + return CodexAppServerClientFactory.of({ + open: (input) => service.pipe(Effect.flatMap((value) => value.open(input))), + }); + }), +); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index f4f123dc1a25f..dd896e9e63901 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -12,8 +12,8 @@ import * as Scope from "effect/Scope"; import * as Types from "effect/Types"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as CodexClient from "effect-codex-app-server/client"; -import * as CodexSchema from "effect-codex-app-server/schema"; +import type * as CodexClient from "effect-codex-app-server/client"; +import type * as CodexSchema from "effect-codex-app-server/schema"; import * as CodexErrors from "effect-codex-app-server/errors"; import type { @@ -446,6 +446,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu }), ), ); + const CodexClient = yield* Effect.promise(() => import("effect-codex-app-server/client")); const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 7a77aa6beb1ab..461f293eb7d2d 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -50,7 +50,7 @@ import { } from "../acp/AntigravitySessionFiles.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; -import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; import { ProviderDriverError } from "../Errors.ts"; import { makeAntigravityProvider } from "../AntigravityProvider.ts"; @@ -411,29 +411,39 @@ export const AntigravityDriver: ProviderDriver< ), ); const defaultModel = currentCatalog.pipe(Effect.map((catalog) => catalog?.defaultChatModel)); - const orchestrationAdapter = yield* makeAntigravityAdapterV2({ + const orchestrationAdapter = yield* makeLazyAdapter( instanceId, - selfInvocation, - makeRuntime, - withProcess: authFlow.withProcess, - defaultModel, - onSessionStarted: provider.onSessionStarted, - onSessionEvent: (event) => { - if (event._tag === "ConfigOptionsUpdated") { - return provider.onConfigOptionsUpdated(event.configOptions); - } - return event._tag === "AvailableCommandsUpdated" - ? provider.onAvailableCommands(event.availableCommands) - : Effect.void; - }, - continuationRequests, - nativeLogging: (threadId) => - makeNativeLogger({ - nativeEventLogger: loggers.native, - provider: DRIVER, - threadId, - }), - }); + DRIVER, + Effect.promise( + () => import("../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"), + ).pipe( + Effect.flatMap(({ makeAntigravityAdapterV2 }) => + makeAntigravityAdapterV2({ + instanceId, + selfInvocation, + makeRuntime, + withProcess: authFlow.withProcess, + defaultModel, + onSessionStarted: provider.onSessionStarted, + onSessionEvent: (event) => { + if (event._tag === "ConfigOptionsUpdated") { + return provider.onConfigOptionsUpdated(event.configOptions); + } + return event._tag === "AvailableCommandsUpdated" + ? provider.onAvailableCommands(event.availableCommands) + : Effect.void; + }, + continuationRequests, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: loggers.native, + provider: DRIVER, + threadId, + }), + }), + ), + ), + ); const textGeneration = yield* makeAntigravityTextGeneration({ profileDirectory, defaultModel, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 4e5fe8e10480e..e1c1fa31f4734 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -1,3 +1,4 @@ +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; @@ -17,10 +18,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; -import { - createClaudeAdapterV2, - type ClaudeAdapterV2DriverEnv, -} from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import type { ClaudeAdapterV2DriverEnv } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import { ProviderDriverError } from "../Errors.ts"; import { claudeUsageReader } from "./claudeUsage.ts"; import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts"; @@ -167,25 +165,33 @@ export const ClaudeDriver: ProviderDriver snapshot.applyUsageLimits(update) }, - ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Claude orchestration adapter.", - cause, - }), + const orchestrationAdapter = yield* makeLazyAdapter( + instanceId, + DRIVER_KIND, + Effect.promise(() => import("../../orchestration-v2/Adapters/ClaudeAdapterV2.ts")).pipe( + Effect.flatMap(({ createClaudeAdapterV2 }) => + createClaudeAdapterV2( + { + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }, + { scopedLimitNames, onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Claude orchestration adapter.", + cause, + }), + ), + ), + ), ), ); const textGeneration = yield* makeClaudeTextGeneration( diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index ab2caf9aafb61..beefedb5d8f6b 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -1,3 +1,4 @@ +import * as CodexAppServerClientFactoryService from "../CodexAppServerClientFactory.ts"; import * as CodexInstallation from "../CodexInstallation.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; @@ -36,7 +37,6 @@ import { resolveLatestProviderVersion, } from "@t3tools/provider-core/server/maintenanceResolver"; import { CodexDriver } from "./CodexDriver.ts"; -import * as CodexAdapterV2 from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderCredentialStore from "../ProviderCredentialStore.ts"; @@ -49,7 +49,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { Layer.provideMerge(IdAllocator.layer), Layer.provideMerge(McpProviderSessions.layer), Layer.provideMerge( - Layer.mock(CodexAdapterV2.CodexAppServerClientFactory)({ + Layer.mock(CodexAppServerClientFactoryService.CodexAppServerClientFactory)({ open: () => Effect.die("Maintenance resolution must not open a Codex session"), }), ), @@ -137,7 +137,9 @@ it.layer(layerTest)("CodexDriver", (it) => { const serverConfig = yield* ServerConfig.ServerConfig; const sharedHome = NodePath.join(serverConfig.stateDir, "shared-codex-home"); const launches: Array< - Parameters[0] + Parameters< + CodexAppServerClientFactoryService.CodexAppServerClientFactory["Service"]["open"] + >[0] > = []; let interruptedChecks = 0; @@ -168,8 +170,8 @@ it.layer(layerTest)("CodexDriver", (it) => { config: { ...CodexDriver.defaultConfig(), setupMode: "managed", homePath: sharedHome }, }).pipe( Effect.provideService( - CodexAdapterV2.CodexAppServerClientFactory, - CodexAdapterV2.CodexAppServerClientFactory.of({ + CodexAppServerClientFactoryService.CodexAppServerClientFactory, + CodexAppServerClientFactoryService.CodexAppServerClientFactory.of({ open: (launch) => Effect.sync(() => launches.push(launch)).pipe( Effect.andThen(Effect.die("The fixture stops after recording the launch")), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 2388213eed301..4150108a93fe9 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,3 +1,4 @@ +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -13,10 +14,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; -import { - createCodexAdapterV2, - type CodexAdapterV2DriverEnv, -} from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import type { CodexAdapterV2DriverEnv } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { ProviderDriverError } from "../Errors.ts"; import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; import { @@ -166,25 +164,33 @@ export const CodexDriver: ProviderDriver snapshot.applyUsageLimits(update) }, - ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Codex orchestration adapter.", - cause, - }), + const orchestrationAdapter = yield* makeLazyAdapter( + instanceId, + DRIVER_KIND, + Effect.promise(() => import("../../orchestration-v2/Adapters/CodexAdapterV2.ts")).pipe( + Effect.flatMap(({ createCodexAdapterV2 }) => + createCodexAdapterV2( + { + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }, + { onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Codex orchestration adapter.", + cause, + }), + ), + ), + ), ), ); diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index e3cede11fdea0..f6cc508ffba53 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -10,7 +10,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { chatGptModels } from "../CodexChatGptModels.ts"; import { makeCodexManagedRuntime } from "../CodexManagedRuntime.ts"; import { ProviderDriverError } from "../Errors.ts"; -import { createCodexAdapterV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter"; import { checkCodexProviderStatus, makePendingCodexProvider, @@ -227,18 +227,26 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu const resolveRuntime = runtime.auth.controller.withAccess!(runtime.resolve); // Launch settings resolve per session from the signed-in token. The registry // already wraps openSession in withAccess, so resolve without re-entering it. - const orchestrationAdapter = yield* createCodexAdapterV2(input, { - onUsageLimits: (update) => snapshot.applyUsageLimits(update), - resolveRuntime: runtime.resolve, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER, - instanceId, - detail: "Failed to build Codex orchestration adapter.", - cause, - }), + const orchestrationAdapter = yield* makeLazyAdapter( + instanceId, + DRIVER, + Effect.promise(() => import("../../orchestration-v2/Adapters/CodexAdapterV2.ts")).pipe( + Effect.flatMap(({ createCodexAdapterV2 }) => + createCodexAdapterV2(input, { + onUsageLimits: (update) => snapshot.applyUsageLimits(update), + resolveRuntime: runtime.resolve, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Failed to build Codex orchestration adapter.", + cause, + }), + ), + ), + ), ), ); const nativeGeneration = yield* makeCodexTextGeneration( diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 266de2339137f..42f4b379d3af0 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -1,7 +1,7 @@ +import * as CodexAppServerClientFactoryService from "./CodexAppServerClientFactory.ts"; +import * as ClaudeAgentSdkQueryRunnerService from "./ClaudeAgentSdkQueryRunner.ts"; import * as Layer from "effect/Layer"; -import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; -import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk"; import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; @@ -9,8 +9,8 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; export type ProviderOrchestrationAdapterInfrastructure = - | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner - | CodexAdapterV2.CodexAppServerClientFactory + | ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner + | CodexAppServerClientFactoryService.CodexAppServerClientFactory | CursorAgentSdk.CursorAgentSdkRunner | CursorSdk.CursorSdk | CursorKeychain.CursorKeychain @@ -22,8 +22,8 @@ export type ProviderOrchestrationAdapterInfrastructure = * memoization yields one shared queue. */ export const layer = Layer.mergeAll( - ClaudeAdapterV2.layerQueryRunner, - CodexAdapterV2.layerAppServerClientFactory, + ClaudeAgentSdkQueryRunnerService.layer, + CodexAppServerClientFactoryService.layer, CursorAgentSdk.layer.pipe(Layer.provideMerge(CursorSdk.layer)), CursorKeychain.layer, IdAllocator.layer, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 9b9f2b60c9871..ad6a74d684451 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -630,6 +630,38 @@ it.layer( }); describe("ProviderRegistry.layer", () => { + it("defers catalogs in config without changing the full provider snapshot", () => { + const provider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [ + { slug: "gpt-live-codex", name: "GPT Live Codex", isCustom: false, capabilities: null }, + ], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + } satisfies ServerProvider; + const providers = [{ ...provider, workspaceSnapshots: [{ ...provider, cwd: "/project" }] }]; + + assert.deepStrictEqual( + ProviderRegistry.selectProvidersForConfig(providers, { deferCatalogs: true }), + [{ ...provider, skills: [], slashCommands: [] }], + ); + assert.strictEqual( + ProviderRegistry.selectProvidersForConfig(providers, { deferCatalogs: false }), + providers, + ); + assert.strictEqual(ProviderRegistry.selectProvidersForConfig(providers, {}), providers); + assert.deepStrictEqual(providers[0]?.skills, provider.skills); + assert.deepStrictEqual(providers[0]?.slashCommands, provider.slashCommands); + assert.strictEqual(providers[0]?.workspaceSnapshots.length, 1); + }); + it("stores workspace skills and commands without changing machine metadata", () => { const provider = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index bf05710737305..b425d0e489f13 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -45,6 +45,19 @@ import type { ProviderSnapshotSource } from "./builtInProviderCatalog.ts"; export type ProviderMaintenanceActionKind = "update"; +export function selectProvidersForConfig( + providers: ReadonlyArray, + options: { readonly deferCatalogs?: boolean }, +): ReadonlyArray { + return options.deferCatalogs === true + ? providers.map(({ workspaceSnapshots: _workspaces, ...provider }) => ({ + ...provider, + skills: [], + slashCommands: [], + })) + : providers; +} + export class ProviderRegistry extends Context.Service< ProviderRegistry, { diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 065bce7271172..93648ea13a816 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -1,15 +1,15 @@ import { AcpRegistryDriver, type AcpRegistryDriverEnv, -} from "@t3tools/provider-acp-registry/server"; +} from "@t3tools/provider-acp-registry/server/driver"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; -import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; -import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; -import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; -import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; -import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; +import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server/driver"; +import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server/driver"; +import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server/driver"; +import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server/driver"; +import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server/driver"; import type { AnyProviderDriver, ProviderUsageReaderEnv, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b3d7870b5dcd0..76a8f2c80f54c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -11,6 +11,7 @@ import * as DelegatedTaskQuestionReactor from "./orchestration-v2/DelegatedTaskQ // @effect-diagnostics nodeBuiltinImport:off import * as NodeHttp from "node:http"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentHttpApi, type RepositoryIdentity } from "@t3tools/contracts"; @@ -22,7 +23,7 @@ import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import * as Schedule from "effect/Schedule"; -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/http"; +import { Etag, FetchHttpClient, HttpRouter, HttpServer } from "effect/http"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; @@ -38,6 +39,7 @@ import { fixPath } from "./os-jank.ts"; import * as Ws from "./ws.ts"; import * as AgentScopeLive from "./process/agentScope.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; +import * as SpawnHost from "./process/SpawnHost.ts"; import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; import * as PullRequestHttp from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; @@ -185,7 +187,6 @@ import * as OrchestrationHttp from "./orchestration-v2/http.ts"; import * as ProjectHttp from "./project/http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; import * as ServerActivation from "./serverActivation.ts"; // MCP handoff thread IDs include escaped provenance and can exceed find-my-way's @@ -292,7 +293,9 @@ const layerHttpServer = Layer.unwrap( const config = yield* ServerConfig.ServerConfig; const createServer = () => holdRequestsUntilServing(guardHttpResponseWriteErrors(NodeHttp.createServer())); - return NodeHttpServer.layer(createServer, { + // `NodeHttpServer.layer` would also re-export the stock Node platform services, + // replacing the spawn host's `ChildProcessSpawner` for everything built on it. + const layerServer = NodeHttpServer.layerServer(createServer, { host: config.host ?? "127.0.0.1", port: config.port, gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, @@ -302,10 +305,12 @@ const layerHttpServer = Layer.unwrap( // `threshold` when context takeover is disabled). websocket: { perMessageDeflate: true }, }); + return Layer.mergeAll(layerServer, NodeHttpPlatform.layer, Etag.layerWeak); }), ); -const layerPlatformServices = NodeServices.layer; +// Children start from the spawn host, so forking never blocks this event loop. +const layerPlatformServices = SpawnHost.layer.pipe(Layer.provideMerge(NodeServices.layer)); const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.layerConfig)); @@ -785,6 +790,9 @@ const layerMakeServer = Layer.unwrap( } const localPort = address.port; + const { ensureTailscaleServe } = yield* Effect.promise( + () => import("@t3tools/tailscale"), + ); return yield* ensureTailscaleServe({ localPort, servePort: config.tailscaleServePort, @@ -808,19 +816,25 @@ const layerMakeServer = Layer.unwrap( }), (configured) => configured - ? disableTailscaleServe({ servePort: configured.servePort }).pipe( - Effect.tap(() => - Effect.logInfo("Tailscale Serve disabled", { - servePort: configured.servePort, - }), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to disable Tailscale Serve", { - cause, - servePort: configured.servePort, - }), - ), - ) + ? Effect.promise(() => import("@t3tools/tailscale")) + .pipe( + Effect.flatMap(({ disableTailscaleServe }) => + disableTailscaleServe({ servePort: configured.servePort }), + ), + ) + .pipe( + Effect.tap(() => + Effect.logInfo("Tailscale Serve disabled", { + servePort: configured.servePort, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to disable Tailscale Serve", { + cause, + servePort: configured.servePort, + }), + ), + ) : Effect.void, ), ) diff --git a/apps/server/src/serviceDatabaseBackup.ts b/apps/server/src/serviceDatabaseBackup.ts new file mode 100644 index 0000000000000..3ad08e3af10cc --- /dev/null +++ b/apps/server/src/serviceDatabaseBackup.ts @@ -0,0 +1,68 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The standalone launcher uses Node built-ins only. +import * as NodeSqlite from "node:sqlite"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +function openWalReader(databasePath: string) { + let source: NodeSqlite.DatabaseSync | undefined; + try { + source = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + source.exec("PRAGMA busy_timeout = 5000; BEGIN"); + source.prepare("SELECT name FROM sqlite_schema LIMIT 1").get(); + if (source.prepare("PRAGMA journal_mode").get()?.journal_mode === "wal") return source; + } catch { + // Fall back to the stopped copy. + } + source?.close(); + return undefined; +} + +/** + * Pin WAL frames with a read transaction until shutdown, then overlay the final + * WAL on the online snapshot so rollback keeps writes made during the copy. + */ +export async function prepareServiceDatabaseBackup(databasePath: string, stagingDir: string) { + const source = openWalReader(databasePath); + if (source === undefined) return undefined; + try { + await NodeFSP.rm(stagingDir, { recursive: true, force: true }); + await NodeFSP.mkdir(stagingDir, { recursive: true, mode: 0o700 }); + const destination = NodePath.join(stagingDir, "database"); + await NodeSqlite.backup(source, destination, { rate: 256 }); + // Enable WAL before installing it; do not open the destination again before restore. + const snapshot = new NodeSqlite.DatabaseSync(destination); + try { + snapshot.exec("PRAGMA journal_mode = WAL"); + } finally { + snapshot.close(); + } + const file = await NodeFSP.open(destination, "r+"); + try { + await file.sync(); + } finally { + await file.close(); + } + return { + async finish() { + try { + await NodeFSP.copyFile(`${databasePath}-wal`, NodePath.join(stagingDir, "database-wal")); + const wal = await NodeFSP.open(NodePath.join(stagingDir, "database-wal"), "r+"); + try { + await wal.sync(); + } finally { + await wal.close(); + } + } catch (cause) { + if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT")) throw cause; + } + }, + close() { + source.close(); + }, + }; + } catch (cause) { + source.close(); + await NodeFSP.rm(stagingDir, { recursive: true, force: true }); + throw cause; + } +} diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 3381b76305369..b668679753780 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -7,7 +7,11 @@ import { SERVER_EXIT_CODE_STATE_DIR_OWNED } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { vi } from "vite-plus/test"; +import * as NodeSqlite from "node:sqlite"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - injects staging failures into the standalone Node backup helper. +import * as NodeFSP from "node:fs/promises"; +import { prepareServiceDatabaseBackup } from "./serviceDatabaseBackup.ts"; +import { expect, vi } from "vite-plus/test"; import { databaseBackupBlocker, @@ -28,6 +32,8 @@ import { const spawnedChildren = vi.hoisted((): Array => []); +vi.mock("node:fs/promises", { spy: true }); + vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); const spawn = (...args: Parameters) => { @@ -126,6 +132,85 @@ const writeFakeRuntime = ( }); it.layer(NodeServices.layer)("service state persistence", (it) => { + it.effect.each([ + { operation: "rm", code: "ENOTDIR" }, + { operation: "mkdir", code: "EACCES" }, + ] as const)( + "online backup releases its WAL reader when staging $operation fails", + ({ operation, code }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-backup-staging-" }); + const sourcePath = path.join(root, "source.sqlite"); + const writer = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(sourcePath)), + (db) => Effect.sync(() => db.close()), + ); + writer.exec( + "PRAGMA journal_mode=WAL; CREATE TABLE records(value TEXT); INSERT INTO records VALUES('before'); PRAGMA wal_checkpoint(TRUNCATE)", + ); + assert.equal(writer.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get()?.busy, 0); + yield* Effect.promise(async () => { + const failure = Object.assign(new Error(`staging ${operation} failed`), { code }); + const setup = vi.mocked(NodeFSP[operation]).mockRejectedValueOnce(failure); + try { + await expect( + prepareServiceDatabaseBackup(sourcePath, path.join(root, "backup")), + ).rejects.toMatchObject({ code }); + } finally { + setup.mockRestore(); + } + }); + writer.exec("INSERT INTO records VALUES('after')"); + assert.equal(writer.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get()?.busy, 0); + }), + ); + + it.effect("online update backups retain acknowledged writes after the snapshot", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-online-backup-" }); + const sourcePath = path.join(root, "source.sqlite"); + const writer = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(sourcePath)), + (db) => Effect.sync(() => db.close()), + ); + writer.exec( + "PRAGMA journal_mode=WAL; CREATE TABLE records(id INTEGER PRIMARY KEY, value TEXT); INSERT INTO records VALUES(1,'before'); PRAGMA wal_checkpoint(TRUNCATE)", + ); + const prepared = yield* Effect.acquireRelease( + Effect.tryPromise(async () => { + const backup = await prepareServiceDatabaseBackup(sourcePath, path.join(root, "backup")); + if (backup === undefined) throw new Error("A WAL database is copied online."); + return backup; + }), + (backup) => Effect.sync(() => backup.close()), + ); + writer.exec( + "UPDATE records SET value='acknowledged'; INSERT INTO records VALUES(2,'after'); CREATE TABLE later(id INTEGER)", + ); + assert.equal(writer.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get()?.busy, 1); + yield* Effect.tryPromise(() => prepared.finish()); + const restored = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(path.join(root, "backup", "database"))), + (db) => Effect.sync(() => db.close()), + ); + assert.deepEqual( + restored + .prepare("SELECT * FROM records ORDER BY id") + .all() + .map((row) => ({ ...row })), + [ + { id: 1, value: "acknowledged" }, + { id: 2, value: "after" }, + ], + ); + assert.equal(restored.prepare("PRAGMA integrity_check").get()?.integrity_check, "ok"); + assert.equal(restored.prepare("SELECT COUNT(*) AS n FROM later").get()?.n, 0); + }), + ); it.effect("durably replaces and strictly reads one state document", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -228,6 +313,9 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); const statePath = path.join(root, "runtime", "service-state.json"); + const stagingDir = path.join(root, "runtime", "db-backup", "failed-online-backup.staging"); + yield* fs.makeDirectory(stagingDir, { recursive: true }); + yield* fs.writeFileString(path.join(stagingDir, "database"), "incomplete online backup"); yield* writeFakeRuntime( fs, path, @@ -238,6 +326,12 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { writeServiceState(statePath, { protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.0.0", + update: { + id: "failed-online-backup", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + status: "failed", + }, }), ); @@ -247,6 +341,7 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE))); yield* Effect.promise(() => stopping); yield* Effect.promise(() => running); + assert.isFalse(yield* fs.exists(stagingDir)); }), ); @@ -357,6 +452,67 @@ if (context.update?.status === "pending") { }), ); + it.effect("restores an online backup when a trial migration fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-online-rollback-" }); + const databasePath = path.join(root, "userdata", "state.sqlite"); + yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); + const original = new NodeSqlite.DatabaseSync(databasePath); + original.exec( + "PRAGMA journal_mode=WAL; CREATE TABLE records(value TEXT); INSERT INTO records VALUES('before')", + ); + original.close(); + const childSource = ` +import { DatabaseSync } from "node:sqlite"; +const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +if (context.update?.status === "pending") { + const db = new DatabaseSync(context.update.dbPath); + db.exec("CREATE TABLE trial_migration(id INTEGER); UPDATE records SET value='trial'"); + db.close(); + process.exit(1); +} else if (context.update === undefined) { + process.send({type:"request-update",targetVersion:"1.1.0",dbPath:${JSON.stringify(databasePath)}}); + setInterval(() => {}, 1000); +} else process.exit(0); +`; + for (const version of ["1.0.0", "1.1.0"]) { + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); + } + const launcher = new Launcher(root, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }); + yield* Effect.promise(() => launcher.run().catch(() => undefined)); + const state = yield* Effect.promise(() => + readServiceState(path.join(root, "runtime", "service-state.json")), + ); + assert.equal(state.activeVersion, "1.0.0"); + assert.equal(state.update?.status, "rolled-back"); + const restored = new NodeSqlite.DatabaseSync(databasePath); + try { + assert.equal(restored.prepare("SELECT value FROM records").get()?.value, "before"); + assert.equal( + restored + .prepare("SELECT COUNT(*) n FROM sqlite_schema WHERE name='trial_migration'") + .get()?.n, + 0, + ); + assert.equal(restored.prepare("PRAGMA integrity_check").get()?.integrity_check, "ok"); + } finally { + restored.close(); + } + }), + ); + + // Migrations on a large database can keep a trial busy for minutes before it + // reports prepared. Only a trial that stays stuck past the window rolls back. it.effect.each([ { prepares: true, status: "committed", activeVersion: "1.1.0" }, { prepares: false, status: "rolled-back", activeVersion: "1.0.0" }, diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 86470023dcb18..687735d8bb6f3 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -9,6 +9,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import { prepareServiceDatabaseBackup } from "./serviceDatabaseBackup.ts"; import type { PendingServiceUpdate, @@ -333,8 +334,10 @@ async function restoreDatabaseBackup( async function discardDatabaseBackup(baseDir: string, updateId: string): Promise { const backupDir = databaseBackupDir(baseDir, updateId); - if (!(await pathExists(backupDir))) return; + const stagingDir = `${backupDir}.staging`; + if (!(await pathExists(backupDir)) && !(await pathExists(stagingDir))) return; await NodeFSP.rm(backupDir, { recursive: true, force: true }); + await NodeFSP.rm(stagingDir, { recursive: true, force: true }); await syncDirectory(NodePath.dirname(backupDir)); } @@ -699,8 +702,35 @@ export class Launcher { return; } this.#timer = undefined; + // Back up every update: even a trial without migrations can write incompatible payloads. + const backupDir = databaseBackupDir(this.#baseDir, pending.id); + const stagingDir = `${backupDir}.staging`; + let prepared: Awaited> | undefined; + try { + if (!(await pathExists(backupDir))) { + prepared = await prepareServiceDatabaseBackup(pending.dbPath, stagingDir); + } + } catch { + // The accepted update must proceed; #startTrial falls back to a stopped copy. + prepared = undefined; + } this.#child = null; - await terminateChild(child.process); + try { + await terminateChild(child.process); + if (prepared !== undefined) { + await prepared.finish(); + await syncDirectory(stagingDir); + await NodeFSP.rename(stagingDir, backupDir); + await syncDirectory(NodePath.dirname(backupDir)); + } + } catch { + prepared?.close(); + prepared = undefined; + await this.#returnToPrevious(pending, "failed", "db-backup-failed"); + return; + } finally { + prepared?.close(); + } await this.#startTrial(pending); } diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 3bb561aef2537..07a32b47b127c 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -38,7 +38,7 @@ import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { mergePathEntries } from "@t3tools/shared/shell"; -import { acpRegistryManagedBinaryDirectories } from "@t3tools/provider-acp-registry/server"; +import { acpRegistryManagedBinaryDirectories } from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 716c6ddd6bc7b..cf0f9c1bf42cc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -742,6 +742,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; readonly acceptCompactTurnItems?: boolean; + readonly acceptCompactCheckpointItems?: boolean; readonly acceptThreadFieldEvents?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; @@ -869,6 +870,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ? buildBoundedThreadStreamSnapshot({ ...snapshot, compactTurnItems: input.acceptCompactTurnItems === true, + compactCheckpointItems: input.acceptCompactCheckpointItems === true, }) : { kind: "snapshot" as const, @@ -972,6 +974,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc function* (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; + readonly compactPullRequests?: boolean; }) { const sql = yield* SqlClient.SqlClient; const threadManagement = yield* ThreadManagementService.ThreadManagementService; @@ -1000,14 +1003,20 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc const base = buildActiveShellSnapshot( yield* loadShellSnapshotParts({ sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), + readThreads: threadManagement.readShellSnapshot({ + location: "active", + compactPullRequests: input.compactPullRequests === true, + }), listProjects: projects.listShells(), latestSequence: applicationEvents.latestApplicationSequence, }), ); const enriched = yield* enrichProjectShells(base.projects); return { - snapshot: { ...base, projects: enriched.projects } as OrchestrationV2ShellSnapshot, + snapshot: { + ...base, + projects: enriched.projects, + } as OrchestrationV2ShellSnapshot, resolvedRepositoryIdentityRoots: enriched.resolvedRepositoryIdentityRoots, }; }); @@ -1046,8 +1055,13 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc if ("aggregateKind" in stored) { return yield* projectItem(stored); } - const shell = yield* threadManagement.getThreadShell(stored.event.threadId); - return shellStreamItemFromThreadShell({ stored, shell }); + const shell = yield* threadManagement.getThreadShell(stored.event.threadId, { + compactPullRequests: input.compactPullRequests === true, + }); + return shellStreamItemFromThreadShell({ + stored, + shell, + }); }), { concurrency: 8 }, ); @@ -1691,23 +1705,35 @@ const layerWsRpc = ( ), ); - const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => + const loadServerConfig = (options: { + readonly usageLimitsCommand: boolean; + readonly deferEditors?: boolean; + readonly deferCatalogs?: boolean; + }) => Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; const currentProviders = yield* providerRegistry.getProviders; - const providers = options.usageLimitsCommand + const completeProviders = options.usageLimitsCommand ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) : currentProviders; + const providers = ProviderRegistry.selectProvidersForConfig(completeProviders, options); const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; - const editorConfig = yield* resolveEditorConfig( - yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), - resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), - ); + const editorConfig = + options.deferEditors === true + ? { availableEditors: [] } + : yield* resolveEditorConfig( + yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ), + resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ), + ); return { environment, @@ -2953,7 +2979,11 @@ const layerWsRpc = ( Stream.unwrap( Effect.gen(function* () { const usageLimitsCommand = input.usageLimitsCommand === true; - const config = yield* loadServerConfig({ usageLimitsCommand }); + const config = yield* loadServerConfig({ + usageLimitsCommand, + deferEditors: input.configDeltas === true, + deferCatalogs: input.configDeltas === true, + }); yield* ServerActivation.markClientServed; const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 5c6176b28c06d..f351931fa0cb9 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -1,4 +1,6 @@ import "vite-plus/test/config"; +import * as Effect from "effect/Effect"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; @@ -35,6 +37,9 @@ if (!TELEMETRY_CHANNELS.includes(telemetryChannel)) { // a separate mode rather than a second entry in the default build. const packExecutable = process.env.T3CODE_PACK_EXE === "1"; const SEA_NODE_VERSION = "26.8.2"; +const [hostPlatform, hostArch] = Effect.runSync( + Effect.all([HostProcess.Platform, HostProcess.Architecture]), +); const SEA_TARGETS = { "darwin-arm64": { platform: "darwin", arch: "arm64" }, "darwin-x64": { platform: "darwin", arch: "x64" }, @@ -83,9 +88,16 @@ export default mergeConfig( fileName: "t3", outDir: "dist-exe", ...(packExecutableTargets ? { targets: packExecutableTargets } : {}), - // Node's SEA docs: `import()` does not work when useCodeCache is - // true, and the server reaches several modules that way. - seaConfig: { useCodeCache: false }, + // Node 26's module SEA supports dynamic builtin imports with code cache. + // Release archives build natively; retain uncached cross-build support. + seaConfig: { + useCodeCache: + !packExecutableTarget || + (SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS].platform === + (hostPlatform === "win32" ? "win" : hostPlatform) && + SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS].arch === + hostArch), + }, }, } : {}), diff --git a/apps/web/src/bootstrap.ts b/apps/web/src/bootstrap.ts index 9a3d4a03b3caf..839b74a02d101 100644 --- a/apps/web/src/bootstrap.ts +++ b/apps/web/src/bootstrap.ts @@ -1,5 +1,17 @@ import { showBootError } from "./lib/bootError"; +import { restoreStackTraces, suspendStackTracesForAppImport } from "./lib/bootStackTraces"; // Bundled dev can move UI code into shared chunks. Load it only after this // entry runs the React refresh preamble, and catch failures before React mounts. -void import("./main").then(({ startup }) => startup).catch(showBootError); +suspendStackTracesForAppImport(); +void import("./main") + .then(({ startup }) => startup) + .catch((error: unknown) => { + const duringAppImport = restoreStackTraces(); + showBootError(error); + if (duringAppImport) { + console.error( + "Stack traces were off while the app's modules loaded (Error.stackTraceLimit = 0), so this error has no stack frames.", + ); + } + }); diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index 26aa15c08fae4..378f776471a38 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -1,9 +1,10 @@ "use client"; -import { useLayoutEffect, useRef } from "react"; +import { useContext, useLayoutEffect, useRef } from "react"; import { acquireBrowserSurface } from "./browserSurfaceStore"; import { observeResize } from "../lib/observeResize"; +import { KeptViewWarmingContext } from "../components/keptView"; export function BrowserSurfaceSlot(props: { readonly tabId: string; @@ -16,13 +17,16 @@ export function BrowserSurfaceSlot(props: { }) { const { tabId, - visible, cornerRadius = 0, zIndex = 30, layoutVersion, className, fitSourceContent = false, } = props; + // A thread view warming up out of sight is invisible only to CSS; the + // native surface would show over the thread on screen. + const warming = useContext(KeptViewWarmingContext); + const visible = props.visible && !warming; const elementRef = useRef(null); const presentationRef = useRef({ visible, cornerRadius, zIndex }); const updateRef = useRef<(() => void) | null>(null); diff --git a/apps/web/src/bundledDev.test.ts b/apps/web/src/bundledDev.test.ts index 0b11c52a5e3de..1e91ae89ff4bc 100644 --- a/apps/web/src/bundledDev.test.ts +++ b/apps/web/src/bundledDev.test.ts @@ -29,7 +29,12 @@ it("initializes React refresh before a shared UI chunk runs in bundled dev", asy try { await NodeFSP.mkdir(NodePath.join(root, "src/lib"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(root, "package.json"), '{"type":"module"}'); - for (const file of ["index.html", "src/bootstrap.ts", "src/lib/bootError.ts"]) { + for (const file of [ + "index.html", + "src/bootstrap.ts", + "src/lib/bootError.ts", + "src/lib/bootStackTraces.ts", + ]) { await NodeFSP.copyFile(new URL(`../${file}`, import.meta.url), NodePath.join(root, file)); } await NodeFSP.writeFile( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b2c2d8540fd17..744c2c1c7a53b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -465,6 +465,25 @@ export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () return () => globalThis.clearTimeout(timeoutId); } +const connectedEnvironmentIds = new Set(); + +/** Records that this page has reached the environment at least once. */ +export function markEnvironmentConnected(environmentId: EnvironmentId): void { + connectedEnvironmentIds.add(environmentId); +} + +/** + * A page that paints from its boot snapshot shows the composer while its + * first connection is still being made, such as a desktop backend that is + * still starting. That first attempt is startup, not a reconnect to warn about. + */ +export function isInitialEnvironmentConnect( + environmentId: EnvironmentId, + phase: string | null, +): boolean { + return phase === "connecting" && !connectedEnvironmentIds.has(environmentId); +} + export function hasEnvironmentReconnectWarningGraceElapsed( activeEnvironmentId: EnvironmentId | null, elapsedEnvironmentId: EnvironmentId | null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b015e09d27c45..f7899bbde28ef 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -47,6 +47,7 @@ import { eventPathContainsSelector, pasteTextToFocusComposer, shouldRedirectInputToComposer, + shouldTypeToFocusComposer, TYPE_TO_FOCUS_EDITABLE_SELECTOR, TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR, } from "./chat/composerFocusRedirect"; @@ -147,6 +148,7 @@ import { Atom } from "effect/reactivity"; import { lazy, Suspense, + useContext, Fragment, type SetStateAction, useEffect, @@ -156,9 +158,18 @@ import { useState, } from "react"; import { flushSync } from "react-dom"; +import { + eraseHeldText, + holdTextForUrlRouteTarget, + isUrlTargetSidebarTextKey, + isUrlRouteTargetElsewhere, + KeptViewWarmingContext, + insertHeldText, +} from "./keptView"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import { isMacPlatform } from "../lib/utils"; +import { queryShownElement } from "../lib/shownElement"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -219,6 +230,7 @@ import { isCommandPaletteOpen } from "../commandPaletteBus"; import { settleAndAdvance } from "../settleAndAdvance"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; +import { useElementState } from "../hooks/useElementState"; import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider"; import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; import { @@ -355,7 +367,7 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; -import { useEnvironmentScope } from "~/state/session"; +import { useEnvironmentScope, useEnvironmentSessionPending } from "~/state/session"; import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, @@ -426,7 +438,6 @@ import { RightPanelMaximizeControl, } from "./chat/PanelLayoutControls"; import type { ExpandedImagePreview } from "./chat/ExpandedImagePreview"; -import { ThreadDetailsPanel } from "./chat/ThreadDetailsPanel"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, @@ -485,6 +496,8 @@ import { deriveComposerSendState, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, + isInitialEnvironmentConnect, + markEnvironmentConnected, scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -642,6 +655,9 @@ const ThreadPreviewMiniPlayer = lazy(() => default: module.ThreadPreviewMiniPlayer, })), ); +const ThreadDetailsPanel = lazy(() => + import("./chat/ThreadDetailsPanel").then((module) => ({ default: module.ThreadDetailsPanel })), +); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; @@ -649,6 +665,36 @@ type EnvironmentUnavailableState = { readonly connection: EnvironmentConnectionPresentation; }; +/** A character typed into the chat composer itself. */ +function isComposerTextKey(event: KeyboardEvent): boolean { + if (event.isComposing || event.metaKey || event.ctrlKey || event.altKey) return false; + return ( + event.key.length === 1 && + event.target instanceof Element && + event.target.closest('[data-testid="composer-editor"]') !== null + ); +} + +const COMPOSER_EDIT_KEYS = new Set(["Enter", "Backspace", "Delete", "ArrowUp", "ArrowDown"]); +const COMPOSER_EDIT_SHORTCUT_KEYS = new Set(["x", "v", "z", "y"]); + +/** + * A key that sends, edits or recalls a prompt in the chat composer itself: + * Enter, Backspace, Delete, the arrows that recall earlier prompts, and cut, + * paste, undo and redo. + */ +function isComposerEditKey(event: KeyboardEvent): boolean { + if (event.isComposing) return false; + if (!(event.target instanceof Element)) return false; + if (event.target.closest('[data-testid="composer-editor"]') === null) return false; + if (COMPOSER_EDIT_KEYS.has(event.key)) return true; + return ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + COMPOSER_EDIT_SHORTCUT_KEYS.has(event.key.toLowerCase()) + ); +} + // Kept per route so a send in flight, and the multiple-model selections, survive // ChatView remounting while the user navigates. const draftFanoutSelectionsAtom = Atom.family((_routeKey: string) => @@ -975,6 +1021,9 @@ function releaseChatTimelineAnchor readEnvironmentScope(environmentId, AuthTerminalOperateScope); @@ -1058,6 +1109,9 @@ export default function ChatView(props: ChatViewProps) { const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const routeThreadRef = scopeThreadRef(environmentId, threadId); const routeThreadKey = scopedThreadKey(routeThreadRef); + // This view's route target, as `threadRouteTargetKey` names it. + const viewRouteTargetKey = + routeKind === "server" ? `server:${routeThreadKey}` : `draft:${draftId}`; const currentRouteThreadKeyRef = useRef(routeThreadKey); useLayoutEffect(() => { currentRouteThreadKeyRef.current = routeThreadKey; @@ -1132,8 +1186,21 @@ export default function ChatView(props: ChatViewProps) { const serverThreadProjection = useThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; const promptRef = useRef(""); - const localComposerRef = useRef(null); - const composerRef = useComposerHandleContext() ?? localComposerRef; + // Each view owns a composer; only the shown view publishes the shared ref. + const composerRef = useRef(null); + const sharedComposerRef = useComposerHandleContext(); + const publishesComposerRef = useRef(false); + const setComposerHandle = useStableCallback((handle: ChatComposerHandle | null) => { + const previous = composerRef.current; + composerRef.current = handle; + if ( + sharedComposerRef !== null && + publishesComposerRef.current && + sharedComposerRef.current === previous + ) { + sharedComposerRef.current = handle; + } + }); const queuedRunEdit = useQueuedRunEdit({ environmentId, routeThreadRef, @@ -1264,7 +1331,7 @@ export default function ChatView(props: ChatViewProps) { const branchToolbarRef = useRef(null); const pasteAsTextShortcutUntilRef = useRef(0); const [restingComposerControlsHost, setRestingComposerControlsHost] = - useState(null); + useElementState(); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); const citeAssistantText = ( citation: AssistantCitation, @@ -1356,9 +1423,15 @@ export default function ChatView(props: ChatViewProps) { const legendListRef = useRef(null); const getTimelineScrollableNode = () => legendListRef.current?.getScrollableNode() ?? null; const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode); - const [composerOverlayElement, setComposerOverlayElement] = useState(null); - const [composerTimelineInset, setComposerTimelineInset] = useState(0); - const composerTimelineInsetRef = useRef(0); + const [composerOverlayElement, setComposerOverlayElement] = useElementState(); + // Space the timeline keeps clear above its end. Tracks the overlay while the + // composer is expanded and holds that height while it rests, so the resting + // composer never exposes rows that its expansion will cover. + // Seed the inset so new views start at the true end before measuring. + const [composerTimelineInset, setComposerTimelineInset] = useState( + () => lastComposerTimelineInset, + ); + const composerTimelineInsetRef = useRef(lastComposerTimelineInset); const composerRestingRef = useRef(false); const composerMountedRef = useRef(true); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); @@ -1983,6 +2056,18 @@ export default function ChatView(props: ChatViewProps) { setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), ); }, [activeReconnectingEnvironmentId]); + const activeEnvironmentIdForConnection = activeEnvironment?.environmentId ?? null; + useEffect(() => { + if ( + activeEnvironmentIdForConnection !== null && + activeEnvironmentConnectionPhase === "connected" + ) { + markEnvironmentConnected(activeEnvironmentIdForConnection); + } + }, [activeEnvironmentConnectionPhase, activeEnvironmentIdForConnection]); + const activeEnvironmentInitialConnect = + activeEnvironmentIdForConnection !== null && + isInitialEnvironmentConnect(activeEnvironmentIdForConnection, activeEnvironmentConnectionPhase); const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = (() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { @@ -2148,6 +2233,17 @@ export default function ChatView(props: ChatViewProps) { }; const handlePreparedPullRequestThreadStable = useStableCallback(handlePreparedPullRequestThread); + const keptViewWarming = useContext(KeptViewWarmingContext); + // Warming views must not take over the shared composer. + useLayoutEffect(() => { + if (sharedComposerRef === null || keptViewWarming) return; + publishesComposerRef.current = true; + sharedComposerRef.current = composerRef.current; + return () => { + publishesComposerRef.current = false; + if (sharedComposerRef.current === composerRef.current) sharedComposerRef.current = null; + }; + }, [keptViewWarming, sharedComposerRef]); const serverConfig = activeThread ? (activeEnvironment?.serverConfig ?? null) : (primaryEnvironment?.serverConfig ?? null); @@ -2264,7 +2360,8 @@ export default function ChatView(props: ChatViewProps) { (unavailableConnection.phase === "connecting" || unavailableConnection.phase === "reconnecting"); const suppressUnavailableBanner = - environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); + environmentReconnecting && + (updateRunning || !reconnectWarningGraceElapsed || activeEnvironmentInitialConnect); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, @@ -3001,6 +3098,7 @@ export default function ChatView(props: ChatViewProps) { const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; useLayoutEffect(() => { if ( + keptViewWarming || threadDetailLoading || timelineEntries.length === 0 || timelineHasEphemeralPreviewUrls(timelineEntries) @@ -3013,7 +3111,14 @@ export default function ChatView(props: ChatViewProps) { markdownCwd: gitCwd, workspaceRoot: activeWorkspaceRoot ?? null, }); - }, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]); + }, [ + activeThreadKey, + activeWorkspaceRoot, + gitCwd, + keptViewWarming, + threadDetailLoading, + timelineEntries, + ]); const heldPaintContext = paintOnlyDisplayedTimeline ? peekHeldThreadTimeline() : null; @@ -4265,7 +4370,7 @@ export default function ChatView(props: ChatViewProps) { event.metaKey || event.shiftKey || eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || - document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) + queryShownElement(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) ) { return; } @@ -4533,7 +4638,8 @@ export default function ChatView(props: ChatViewProps) { frame = window.requestAnimationFrame(() => { frame = window.requestAnimationFrame(() => { frame = null; - if (document.querySelector("[data-mcp-app-fullscreen]") !== null) return; + // A full-screen app owns the page; refocusing the composer would close it. + if (queryShownElement("[data-mcp-app-fullscreen]") !== null) return; if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposerStable(); }); }); @@ -4682,6 +4788,7 @@ export default function ChatView(props: ChatViewProps) { }); if (composerTimelineInsetRef.current !== nextInset) { composerTimelineInsetRef.current = nextInset; + lastComposerTimelineInset = nextInset; setComposerTimelineInset(nextInset); } const mainSurface = composerOverlayElement?.querySelector( @@ -5528,6 +5635,61 @@ export default function ChatView(props: ChatViewProps) { getShortcutContext: getShortcutContextStable, threadFindActive: isThreadFindActive, typeIntoComposer: (key) => composerRef.current?.insertTextAtEnd(key) === true, + interceptKey: (event, shortcutContext) => { + const typesToComposer = + !shortcutContext.terminalFocus && + !shortcutContext.modelPickerOpen && + shouldTypeToFocusComposer(event); + const sidebarTextKey = + !shortcutContext.terminalFocus && + !shortcutContext.modelPickerOpen && + isUrlTargetSidebarTextKey(event) && + !queryShownElement(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR); + + // A kept thread the user just switched to is revealed in a transition; + // text typed meanwhile, into this view's composer or to focus it, goes + // to the thread the URL names. + if ( + isUrlRouteTargetElsewhere(viewRouteTargetKey) && + (typesToComposer || isComposerTextKey(event) || sidebarTextKey) && + holdTextForUrlRouteTarget(viewRouteTargetKey, event.key) + ) { + event.preventDefault(); + event.stopPropagation(); + // Leave the row's activation keys alone; subsequent typing uses type-to-focus. + if (sidebarTextKey && event.target instanceof HTMLElement) event.target.blur(); + return true; + } + // The other keys would send, edit or recall in this thread's draft. + // Backspace takes back held text instead, and the rest do nothing. + const erasedHeldText = + event.key === "Backspace" && + !event.isComposing && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + shouldRedirectInputToComposer(event) && + eraseHeldText(viewRouteTargetKey); + if ( + isUrlRouteTargetElsewhere(viewRouteTargetKey) && + (isComposerEditKey(event) || erasedHeldText) + ) { + if (event.key === "Backspace" && !erasedHeldText) eraseHeldText(viewRouteTargetKey); + event.preventDefault(); + event.stopPropagation(); + return true; + } + + if (typesToComposer || sidebarTextKey) { + if (composerRef.current?.insertTextAtEnd(event.key)) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + } + + return false; + }, closeThreadFind: () => { closeThreadFindStable(); focusComposerStable(); @@ -5648,13 +5810,24 @@ export default function ChatView(props: ChatViewProps) { }, }); - // A focused desktop browser page forwards these chords as menu actions. + // Text typed on the way to this view, while the view the user left was still shown. useEffect(() => { + if (keptViewWarming || isUrlRouteTargetElsewhere(viewRouteTargetKey)) return; + insertHeldText( + viewRouteTargetKey, + (text) => composerRef.current?.insertTextAtEnd(text) === true, + ); + }); + + // A focused desktop browser page forwards these chords as menu actions. They + // are for the shown thread, not one warming up out of sight. + useEffect(() => { + if (keptViewWarming) return; return window.desktopBridge?.onMenuAction((action) => { if (action === "rightPanel.toggle") toggleRightPanelStable(); else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximizedStable(); }); - }, [toggleRightPanelStable, toggleRightPanelMaximizedStable]); + }, [keptViewWarming, toggleRightPanelStable, toggleRightPanelMaximizedStable]); // Paste-to-focus: the resting composer blurs on a click into the timeline, // so a paste that follows has no editable target and would be dropped. @@ -8062,6 +8235,7 @@ export default function ChatView(props: ChatViewProps) { onManualNavigation={cancelTimelineLiveFollowForUserNavigationStable} cancelPositionRestoreRef={cancelPositionRestoreRef} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} + loading={threadDetailLoading && !isDraftHeroState} topFadeEnabled={!hasTimelineTopBanner} {...(paintOnlyDisplayedTimeline || threadHistoryControls === undefined ? {} @@ -8171,7 +8345,7 @@ export default function ChatView(props: ChatViewProps) { true } onMultipleModelSelectionsChange={setMultipleModelSelectionsStable} - composerRef={composerRef} + composerRef={setComposerHandle} composerDraftTarget={composerDraftTarget} environmentId={environmentId} attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} @@ -8208,19 +8382,21 @@ export default function ChatView(props: ChatViewProps) { canResume={resumableRunId !== null || hasHeldQueuedRuns} isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ - !canOperateThread - ? "This connection cannot change threads." - : isEnvironmentChanging - ? "Preparing machine" - : isRevertingCheckpoint - ? "Rewinding conversation" - : feedbackUploading - ? "Sending feedback" - : threadDetailLoading - ? "Messages loading" - : worktreeSetupBlocksSend - ? "Preparing worktree" - : projectCloneSendBlockReason + !canOperateThread && sessionPending + ? "Connecting" + : !canOperateThread + ? "This connection cannot change threads." + : isEnvironmentChanging + ? "Preparing machine" + : isRevertingCheckpoint + ? "Rewinding conversation" + : feedbackUploading + ? "Sending feedback" + : threadDetailLoading + ? "Messages loading" + : worktreeSetupBlocksSend + ? "Preparing worktree" + : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} queuedRunsControl={queuedRunsControl} @@ -8434,58 +8610,60 @@ export default function ChatView(props: ChatViewProps) { onConfirm={handleSwitchCheckoutToThreadStable} /> - + + + store.threadLastVisitedAtById[threadKey], ); + const keptViewWarming = useContext(KeptViewWarmingContext); const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const lastDispatchedVisitRef = useRef(null); const lastVisitDispatchAtRef = useRef(0); + useEffect( + () => () => { + lastDispatchedVisitRef.current = null; + }, + [], + ); useEffect(() => { - if (!serverThread?.id) return; + if (!serverThread?.id || keptViewWarming) return; const threadUpdatedAt = Date.parse(serverThread.updatedAt); if (Number.isNaN(threadUpdatedAt)) return; const effectiveLastVisitedAt = resolveThreadLastVisitedAt( @@ -8703,6 +8888,7 @@ function ThreadVisitWatermark(props: { readonly threadRef: ScopedThreadRef }) { ); }, [ activeThreadLocalLastVisitedAt, + keptViewWarming, markThreadVisited, threadKey, serverThread?.environmentId, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 5ad00e3241cd5..13dd3b254fd10 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -273,6 +273,31 @@ describe("reduceCommandPaletteUiState", () => { openIntent: null, }); }); + + it("keeps text typed while a view loads as that view's query", () => { + const contentOpen = reduceCommandPaletteUiState(closedState, { + _tag: "ToggleMode", + mode: "content", + }); + expect( + reduceCommandPaletteUiState(contentOpen, { _tag: "TypeAhead", query: "fo" }).openIntent, + ).toEqual({ kind: "search", query: "fo" }); + + const linkedThreads = { environmentId: EnvironmentId.make("env"), threads: [] }; + const prSearch = reduceCommandPaletteUiState(closedState, { + _tag: "OpenSearch", + query: "#4", + linkedThreads, + }); + expect( + reduceCommandPaletteUiState(prSearch, { _tag: "TypeAhead", query: "#42" }).openIntent, + ).toEqual({ kind: "search", query: "#42", linkedThreads }); + + const addProject = reduceCommandPaletteUiState(closedState, { _tag: "OpenAddProject" }); + expect(reduceCommandPaletteUiState(addProject, { _tag: "TypeAhead", query: "a" })).toBe( + addProject, + ); + }); }); describe("enumerateCommandPaletteItems", () => { diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 7cd6ac8912138..558d654fe67a3 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -78,7 +78,9 @@ export type CommandPaletteUiAction = | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } | { readonly _tag: "OpenChangeTheme" } - | { readonly _tag: "ClearOpenIntent" }; + | { readonly _tag: "ClearOpenIntent" } + /** Text typed while the open view loads; it becomes that view's query. */ + | { readonly _tag: "TypeAhead"; readonly query: string }; export function reduceCommandPaletteUiState( state: CommandPaletteUiState, @@ -111,6 +113,18 @@ export function reduceCommandPaletteUiState( return { open: true, mode: "command", openIntent: { kind: "change-theme" } }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; + case "TypeAhead": { + const intent = state.openIntent; + if (!state.open || (intent !== null && intent.kind !== "search")) return state; + return { + ...state, + openIntent: { + kind: "search", + query: action.query, + ...(intent?.linkedThreads ? { linkedThreads: intent.linkedThreads } : {}), + }, + }; + } } } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a7b93881e8b6f..c054c33f37488 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -653,6 +653,11 @@ function CommandPaletteDialog(props: { readonly clearOpenIntent: () => void; }) { const composerHandleRef = useComposerHandleContext(); + // File and content search start from text typed while they loaded. + const typedAheadQuery = + props.mode !== "command" && props.openIntent?.kind === "search" && props.openIntent.query + ? { initialQuery: props.openIntent.query } + : {}; return ( {props.mode === "files" ? ( - + ) : props.mode === "content" ? ( - + ) : ( >; +// Document model (markdown ⇄ ProseMirror) lives in ~/composer-rich-text-doc so +// unit tests can round-trip it without a browser. +// ── Editor component ─────────────────────────────────────────────────────── + +type TiptapEditor = Editor; export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { // Extensions are creation-time, so changing text mode remounts the editor. @@ -883,6 +889,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const { value, cursor, + historyScope, richTextEnabled, literalText = false, contextRecords, @@ -1080,7 +1087,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { "aria-placeholder": placeholder, }; - const editor = useEditor( + const editor = useKeptEditor( { extensions: richText ? RICH_TEXT_EXTENSIONS : PLAIN_TEXT_EXTENSIONS, content: buildDocJson( @@ -1579,6 +1586,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { value, ]); + // Runs after the effect above has loaded the new thread's prompt, so undo + // cannot bring back the previous thread's prompt. + const historyScopeRef = useRef(historyScope); + useLayoutEffect(() => { + if (!editor || historyScopeRef.current === historyScope) return; + historyScopeRef.current = historyScope; + const { doc, selection, plugins } = editor.state; + editor.view.updateState(EditorState.create({ doc, selection, plugins })); + }, [editor, historyScope]); + const focusAt = (nextCursor: number) => { if (!editor) return; // view.focus() writes the state selection to the DOM as it focuses. A @@ -1729,7 +1746,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { containerClassName, )} > - { if ( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index b4083eac66b13..634faa2535065 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -188,12 +188,15 @@ interface DiffPanelProps { mode?: DiffPanelMode; composerDraftTarget: ScopedThreadRef | DraftId; workspaceMutationId: string | null; + /** The shown view's thread, which may lag behind the URL during a reveal. */ + threadRef: ScopedThreadRef | null; } export default function DiffPanel({ mode = "inline", composerDraftTarget, workspaceMutationId, + threadRef: routeThreadRef, }: DiffPanelProps) { const { resolvedTheme } = useTheme(); const settings = useClientSettings((settings) => ({ @@ -219,7 +222,6 @@ export default function DiffPanel({ const [codeViewRevision, setCodeViewRevision] = useState(0); const [codeView, setCodeView] = useState(null); - const routeThreadRef = useThreadRouteRef(); const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useQuietThreadShell(routeThreadRef); const fileAccess = useFilesystemReadAccess(activeThread?.environmentId ?? null); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f131bf1be98e9..94808636ab994 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1078,6 +1078,14 @@ export default function GitActionsControl({ }) : null, ); + const gitFilesQuery = useEnvironmentQuery( + isCommitDialogOpen && activeEnvironmentId !== null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: activeEnvironmentId, + input: { cwd: gitCwd, includeFiles: true }, + }) + : null, + ); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); @@ -1087,10 +1095,13 @@ export default function GitActionsControl({ const SourceControlIcon = sourceControlPresentation.Icon; const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; - const gitStatusForActions = gitStatus; + const gitStatusForActions = gitFilesQuery.data ?? gitStatus; + // Matches the diff panel's Changes view. Older servers only report uncommitted totals. const changesTotals = gitStatusForActions?.branchChanges ?? gitStatusForActions?.workingTree; const allFiles = gitStatusForActions?.workingTree.files ?? []; + const areCommitFilesLoading = + gitFilesQuery.data === null && gitFilesQuery.isPending && allFiles.length === 0; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; @@ -1911,7 +1922,11 @@ export default function GitActionsControl({ )} - {!gitStatusForActions || allFiles.length === 0 ? ( + {gitFilesQuery.error !== null ? ( +

{gitFilesQuery.error}

+ ) : areCommitFilesLoading ? ( +

Loading files...

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

none

) : (
diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts index de5a2123cde47..63c4060d807be 100644 --- a/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts @@ -59,6 +59,40 @@ describe("keybindings update toast policy", () => { }); }); + it.each(["snapshot", "seed"])("reports unchanged-bindings recovery after %s", (initial) => { + const controller = createKeybindingsUpdateToastController({}); + const malformed = { + kind: "keybindings.malformed-config", + message: "Expected JSON array", + } as const; + const delta = ( + changes: Extract["changes"], + ): ServerConfigStreamEvent => ({ + version: 1, + type: "configDelta", + configVersion: "v2", + changes, + removed: [], + providerChanges: [], + }); + if (initial === "seed") { + controller.seed([malformed]); + controller.seed([]); + } else + controller.handle({ version: 1, type: "snapshot", config: { issues: [malformed] } as never }); + expect(controller.handle(delta({ issues: [malformed] }))).toBeNull(); + expect(controller.handle(delta({ issues: [] }))).toEqual({ _tag: "Success" }); + expect(controller.handle(delta({ issues: [] }))).toBeNull(); + expect(controller.handle(delta({ issues: [malformed] }))).toEqual({ + _tag: "InvalidConfiguration", + message: malformed.message, + }); + expect(controller.handle(delta({ cwd: "/repo" }))).toBeNull(); + expect(createKeybindingsUpdateToastController({}).handle(delta({ keybindings: [] }))).toEqual({ + _tag: "Success", + }); + }); + it("ignores unrelated server config notifications", () => { const controller = createKeybindingsUpdateToastController({}); diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.ts index f6a47f50cfc65..ac8701b870f76 100644 --- a/apps/web/src/components/KeybindingsUpdateToast.logic.ts +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.ts @@ -1,4 +1,4 @@ -import type { ServerConfigStreamEvent } from "@t3tools/contracts"; +import type { ServerConfig, ServerConfigStreamEvent } from "@t3tools/contracts"; export const KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS = 2_000; @@ -8,6 +8,23 @@ export type KeybindingsUpdateToastDecision = export interface KeybindingsUpdateToastController { readonly handle: (event: ServerConfigStreamEvent | null) => KeybindingsUpdateToastDecision | null; + /** Seed once from boot/unchanged configs that arrive without a snapshot event. */ + readonly seed: (issues: ServerConfig["issues"]) => void; +} + +// Malformed files can change issues without changing keybindings. +function keybindingsUpdateFromEvent(event: ServerConfigStreamEvent | null) { + if (event?.type === "keybindingsUpdated") { + return { keybindingsChanged: true, issues: event.payload.issues }; + } + if (event?.type !== "configDelta") return null; + const { keybindings, issues } = event.changes; + if (keybindings === undefined && issues === undefined) return null; + return { keybindingsChanged: keybindings !== undefined, issues: issues ?? null }; +} + +function keybindingsIssue(issues: ServerConfig["issues"]) { + return issues.find((entry) => entry.kind.startsWith("keybindings.")); } export function createKeybindingsUpdateToastController(input: { @@ -15,19 +32,46 @@ export function createKeybindingsUpdateToastController(input: { }): KeybindingsUpdateToastController { const now = input.now ?? Date.now; let lastSuccessToastAt: number | null = null; + // A delta that only changes other issues must not repeat a standing warning. + let reportedIssueMessage: string | null = null; + let seeded = false; return { + seed: (issues) => { + if (seeded) return; + seeded = true; + reportedIssueMessage = keybindingsIssue(issues)?.message ?? null; + }, handle: (event) => { - if (event?.type !== "keybindingsUpdated") { + // Seed standing issues so clearing one later counts as a fix. + if (event?.type === "snapshot") { + seeded = true; + reportedIssueMessage = keybindingsIssue(event.config.issues)?.message ?? null; + return null; + } + const update = keybindingsUpdateFromEvent(event); + if (update === null) { return null; } - const issue = event.payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); - if (issue) { - return { - _tag: "InvalidConfiguration", - message: issue.message, - }; + let fixed = false; + if (update.issues !== null) { + const issue = keybindingsIssue(update.issues); + const repeated = !update.keybindingsChanged && issue?.message === reportedIssueMessage; + fixed = !issue && reportedIssueMessage !== null; + reportedIssueMessage = issue?.message ?? null; + if (issue && !repeated) { + return { + _tag: "InvalidConfiguration", + message: issue.message, + }; + } + if (issue) { + return null; + } + } + if (!update.keybindingsChanged && !fixed) { + return null; } const currentTime = now(); diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index 0cad2af5009d8..a27ba41fbb07b 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -1,5 +1,6 @@ -import { type ReactNode } from "react"; +import { type ReactNode, useContext } from "react"; +import { KeptViewWarmingContext } from "./keptView"; import { Sheet, SheetPopup } from "./ui/sheet"; export function RightPanelSheet(props: { @@ -8,8 +9,11 @@ export function RightPanelSheet(props: { open: boolean; onClose: () => void; }) { + // A view warming out of sight must not lock the page or hide it from the shown thread. + const warming = useContext(KeptViewWarmingContext); return ( { if (!open) { diff --git a/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx index 38c147764080b..1a9716e11730c 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx @@ -31,6 +31,14 @@ beforeEach(() => { configurable: true, value: () => [], }); + // jsdom has no layout. An element is shown unless an ancestor hides it, as a hidden kept + // thread view hides its popups. + Object.defineProperty(Element.prototype, "checkVisibility", { + configurable: true, + value(this: Element) { + return this.closest('[style*="display: none"]') === null; + }, + }); vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); addFiles.mockClear(); container = document.createElement("div"); @@ -201,6 +209,18 @@ describe("right panel new-tab shortcut", () => { expect(document.querySelector('[role="menu"]')).toBeNull(); }); + it("opens over a popup that a hidden thread view keeps", async () => { + await renderPanel(); + const hiddenView = document.createElement("div"); + hiddenView.style.display = "none"; + const popup = document.createElement("div"); + popup.dataset.slot = "menu-popup"; + hiddenView.append(popup); + container.append(hiddenView); + expect((await press("t", { metaKey: true })).defaultPrevented).toBe(true); + expect(document.querySelector('[role="menu"]')?.textContent).toContain("Linked pull requests"); + }); + it("supports arrow keys with an existing tab in sheet mode and clears the menu on close", async () => { const panel = { mode: "sheet" as const, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index c44de7b9bcbd5..93a563925d513 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -92,6 +92,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { observeResize } from "~/lib/observeResize"; +import { queryShownElement } from "~/lib/shownElement"; interface RightPanelTabsProps { mode: PreviewPanelMode; @@ -979,7 +980,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }) !== "rightPanel.new" ) return; - if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; + if (!addSurfaceMenuOpen && queryShownElement(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; event.preventDefault(); event.stopPropagation(); if (!event.repeat) { diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 12fbd0d550365..95f413c3c2348 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -1,9 +1,25 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import { useNavigate } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { + Activity, + memo, + startTransition, + useDeferredValue, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { flushSync } from "react-dom"; import ChatView from "./ChatView"; +import { + createKeptViewLifetimes, + KeptViewFrame, + KeptViewLifetimeContext, + setUrlRouteTargetKey, +} from "./keptView"; import { resolveDraftPromotionNavigationTarget, threadHasStarted } from "./ChatView.logic"; import { waitForDraftHeroTransition } from "./chat/draftHeroTransition"; import { SidebarInset } from "./ui/sidebar"; @@ -16,19 +32,73 @@ import { import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { useEnvironmentThreadRefs, useThreadRefs, useQuietThreadShell } from "../state/entities"; import { useEnvironmentShellStatus } from "../state/shell"; +import { useThreadStatus } from "../state/entities"; import { buildThreadRouteParams, + finishWarmingThreadView, isThreadRouteSnapshotAuthoritative, + keepReleasedThreadView, + resolveKeptThreadViews, resolveThreadRouteRenderState, + resolveThreadRouteTarget, + threadRouteTargetFromKey, + threadRouteTargetKey, + type KeptThreadView, type ThreadRouteTarget, } from "../threadRoutes"; -/** - * Plain server threads are unkeyed, so navigating between them reuses one instance as ChatView - * expects. Rendered by the `_chat` layout rather than by the two leaf routes, since an element - * only survives a route swap when the same parent renders it. - */ -export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { +// Kept views re-render only when their own thread or route kind changes. +const KeptChatView = memo(ChatView); + +// Updated on commit so route reveals use the mounted view pool. +let keptThreadKeys: ReadonlySet = new Set(); + +const WARM_UP_QUIET_MS = 1_000; +let lastUserInputAt = 0; +let trackingUserInput = false; +// Hide warming views before their live input listeners can handle a user's event. +let interruptWarmUp: (() => void) | null = null; +function trackUserInput() { + if (trackingUserInput || typeof window === "undefined") return; + trackingUserInput = true; + // Register before warming views so their listeners disconnect first. + const record = () => { + lastUserInputAt = performance.now(); + interruptWarmUp?.(); + }; + for (const type of ["keydown", "pointerdown", "wheel", "paste"] as const) { + window.addEventListener(type, record, { capture: true, passive: true }); + } +} + +function whenQuietAndIdle(callback: () => void, initialDelayMs: number): () => void { + let timeout: ReturnType | undefined; + let idle: number | undefined; + const attempt = () => { + const quietFor = performance.now() - lastUserInputAt; + if (quietFor < WARM_UP_QUIET_MS) { + timeout = setTimeout(attempt, WARM_UP_QUIET_MS - quietFor); + } else if (typeof requestIdleCallback === "function") { + idle = requestIdleCallback( + () => { + idle = undefined; + if (performance.now() - lastUserInputAt < WARM_UP_QUIET_MS) attempt(); + else callback(); + }, + { timeout: 2_000 }, + ); + } else { + callback(); + } + }; + timeout = setTimeout(attempt, initialDelayMs); + return () => { + clearTimeout(timeout); + if (idle !== undefined) cancelIdleCallback(idle); + }; +} + +const ThreadRouteView = memo(function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { const navigate = useNavigate(); const draftId = target.kind === "draft" ? target.draftId : null; const draftSession = useComposerDraftStore((store) => @@ -64,25 +134,6 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { const draftThread = useComposerDraftStore((store) => serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, ); - const promotedDraftId = useComposerDraftStore((store) => - target.kind === "server" ? store.getDraftIdByRef(target.threadRef) : null, - ); - // The draft record is removed once the promoted thread has started, which - // is after the route swap. Latch the key so the element that carried the - // draft keeps its identity for as long as this thread stays on screen. - const [chatViewKey, setChatViewKey] = useState<{ threadKey: string; key: string } | null>(null); - const serverThreadKey = target.kind === "server" ? scopedThreadKey(target.threadRef) : null; - const nextChatViewKey = - serverThreadKey === null - ? null - : chatViewKey?.threadKey === serverThreadKey - ? chatViewKey - : promotedDraftId - ? { threadKey: serverThreadKey, key: promotedDraftId } - : null; - if (nextChatViewKey !== chatViewKey) { - setChatViewKey(nextChatViewKey); - } const environmentHasDraftThreads = useComposerDraftStore((store) => serverThreadRef ? store.hasDraftThreadsInEnvironment(serverThreadRef.environmentId) : false, ); @@ -149,34 +200,171 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { finalizePromotedDraftThreadByRef(target.threadRef); }, [draftThread, serverThreadStarted, target]); - let view: React.ReactNode = null; + let activeView: Omit | null = null; if (target.kind === "draft") { if (draftSession) { - view = ( - - ); + activeView = { + key: target.draftId, + // Only the thread fields: a draft session also carries a projectId. + threadKey: scopedThreadKey({ + environmentId: draftSession.environmentId, + threadId: draftSession.threadId, + }), + environmentId: draftSession.environmentId, + threadId: draftSession.threadId, + draftId: target.draftId, + }; } } else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) { - view = ( - - ); + activeView = { + key: scopedThreadKey(target.threadRef), + threadKey: scopedThreadKey(target.threadRef), + environmentId: target.threadRef.environmentId, + threadId: target.threadRef.threadId, + draftId: null, + }; } + const [keptViews, setKeptViews] = useState>([]); + const [releasedView, setReleasedView] = useState(null); + const keptUpdate = resolveKeptThreadViews(keptViews, activeView); + const nextKeptViews = keptUpdate.views; + if (nextKeptViews !== keptViews) { + setKeptViews(nextKeptViews); + if (keptUpdate.released !== null) setReleasedView(keptUpdate.released); + } + // Warm the displaced view after the shown thread loads and input goes quiet. + // Render hidden at idle priority, then lay out at the next quiet moment. + const shownThreadStatus = useThreadStatus(target.kind === "server" ? target.threadRef : null); + const shownThreadLoaded = + target.kind !== "server" || shownThreadStatus === "live" || shownThreadStatus === "deleted"; + useEffect(trackUserInput, []); + useEffect(() => { + if (releasedView === null || !shownThreadLoaded) return; + return whenQuietAndIdle( + () => + startTransition(() => { + setKeptViews((current) => keepReleasedThreadView(current, releasedView)); + setReleasedView(null); + }), + WARM_UP_QUIET_MS, + ); + }, [releasedView, shownThreadLoaded]); + const [laidOutWarmKey, setLaidOutWarmKey] = useState(null); + const warmingKey = keptViews.find((view) => view.warming === true)?.key ?? null; + const layingOutKey = warmingKey !== null && warmingKey === laidOutWarmKey ? warmingKey : null; + useEffect(() => { + if (warmingKey === null || layingOutKey !== null) return; + return whenQuietAndIdle(() => startTransition(() => setLaidOutWarmKey(warmingKey)), 0); + }, [layingOutKey, warmingKey]); + useLayoutEffect(() => { + if (layingOutKey === null) return; + interruptWarmUp = () => flushSync(() => setLaidOutWarmKey(null)); + return () => { + interruptWarmUp = null; + }; + }, [layingOutKey]); + const finishWarming = (viewKey: string) => { + setKeptViews((current) => finishWarmingThreadView(current, viewKey)); + setLaidOutWarmKey(null); + }; + useLayoutEffect(() => { + keptThreadKeys = new Set( + keptViews.flatMap((view) => (view.draftId === null ? [view.threadKey] : [])), + ); + }, [keptViews]); + const [lifetimes] = useState(createKeptViewLifetimes); + const insetRef = useRef(null); + useLayoutEffect(() => { + lifetimes.retain(new Set(keptViews.map((view) => view.key))); + }, [keptViews, lifetimes]); + useEffect(() => { + const inset = insetRef.current; + return () => { + // Activity hides keep lifetimes; only removal releases them. + setTimeout(() => { + if (!inset?.isConnected) lifetimes.retain(new Set()); + }, 0); + }; + }, [lifetimes]); + const activeViewKey = + activeView === null + ? null + : (nextKeptViews.find((view) => + activeView.draftId === null + ? view.threadKey === activeView.threadKey + : view.key === activeView.key, + )?.key ?? null); + // Activity preserves DOM; pause media when its view hides. + useLayoutEffect(() => { + const inset = insetRef.current; + return () => { + for (const media of inset?.querySelectorAll("video, audio") ?? []) { + if (!media.paused) media.pause(); + } + }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each change of the shown view pauses the one it hid. + }, [activeViewKey]); return ( - - {view} + + {nextKeptViews.map((view) => ( + + + + + + + + ))} ); +}); + +/** Hosting above the outlet preserves chat views across full-page navigation. */ +export function KeptChatSurface() { + const targetKey = useParams({ + strict: false, + select: (params) => threadRouteTargetKey(resolveThreadRouteTarget(params)), + }); + // Defer warm reveals so sidebar selection paints first; first opens stay urgent. + const deferredTargetKey = useDeferredValue(targetKey); + const targetThreadKey = targetKey?.startsWith("server:") + ? targetKey.slice("server:".length) + : null; + const reveal = targetThreadKey !== null && keptThreadKeys.has(targetThreadKey); + // Full-page returns have no visible thread to hold during a transition. + const shownKey = reveal && deferredTargetKey !== null ? deferredTargetKey : targetKey; + // Keys typed before a deferred reveal lands go to the URL's thread. + useLayoutEffect(() => setUrlRouteTargetKey(targetKey), [targetKey]); + const [shownTargetKey, setShownTargetKey] = useState(shownKey); + if (shownKey !== null && shownKey !== shownTargetKey) { + setShownTargetKey(shownKey); + } + const target = threadRouteTargetFromKey(shownTargetKey); + if (target === null) return null; + return ( + + + + ); } diff --git a/apps/web/src/components/ThreadTerminalDrawer.permissions.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.permissions.browser.test.tsx index adbc8c9ca5b2b..47718015b1d12 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.permissions.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.permissions.browser.test.tsx @@ -83,6 +83,7 @@ vi.mock(import("../state/session"), async (original) => { }); import { TerminalViewport } from "./ThreadTerminalDrawer"; +import { KeptViewLifetimeContext, createKeptViewLifetime } from "./keptView"; const threadRef = { environmentId: EnvironmentId.make("secondary-terminal"), @@ -112,6 +113,38 @@ afterEach(async () => { vi.unstubAllGlobals(); }); +it("keeps a replacement terminal session visible while the drawer stays open", async () => { + const lifetime = createKeptViewLifetime(); + const viewport = (terminalId: string) => ( + + + + ); + try { + await act(async () => root.render(viewport("terminal-1"))); + expect(state.createSurface.mock.calls[0]![1].visible).toBe(true); + await act(async () => root.render(viewport("terminal-2"))); + expect(state.createSurface).toHaveBeenCalledTimes(2); + expect(state.createSurface.mock.calls[1]![1].visible).toBe(true); + } finally { + lifetime.dispose(); + } +}); + it("rechecks the target terminal grant when a retained surface callback reports a resize", async () => { await act(async () => root.render( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 411444f2a54ed..6e48f440b2771 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -34,6 +34,7 @@ import { type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, + useContext, useEffect, useEffectEvent, useLayoutEffect, @@ -76,6 +77,7 @@ import { type ThreadTerminalGroup, } from "../types"; import { readLocalApi } from "~/localApi"; +import { KeptViewLifetimeContext } from "./keptView"; import { confirmTerminalClose } from "~/lib/terminalCloseConfirm"; import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; @@ -441,9 +443,18 @@ function TerminalSessionViewport({ drawerHeight, keybindings, }: TerminalViewportProps) { + const runtimeEnvKey = runtimeEnvSignature(runtimeEnv); const containerRef = useRef(null); const terminalRef = useRef(null); const visibleRef = useRef(visible); + // A hidden kept thread view keeps the surface (see the setup effect). + const keptViewLifetime = useContext(KeptViewLifetimeContext); + const parkedSurfaceRef = useRef<{ + readonly key: string; + readonly cleanup: () => void; + readonly release: () => void; + readonly cancelRelease: () => void; + } | null>(null); const environmentId = threadRef.environmentId; const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); const hasTerminalWriteAccess = useEffectEvent(() => @@ -599,6 +610,20 @@ function TerminalSessionViewport({ useEffect(() => { const mount = containerRef.current; if (!mount) return; + // Session cleanup can hide the surface while the drawer remains visible. + visibleRef.current = visible; + terminalRef.current?.setVisible(visible); + const surfaceKey = [environmentId, threadId, terminalId, cwd, worktreePath, runtimeEnvKey].join( + "\u0000", + ); + const parked = parkedSurfaceRef.current; + if (parked !== null) { + parkedSurfaceRef.current = null; + parked.cancelRelease(); + // Shown again for the same session: the surface was kept as it was. + if (parked.key === surfaceKey) return parked.cleanup; + parked.release(); + } const localApi = readLocalApi(); let cancelled = false; @@ -1040,7 +1065,7 @@ function TerminalSessionViewport({ mount.textContent = `${message} — close and reopen the terminal to retry.`; }); - return () => { + const release = () => { cancelled = true; const hadFocus = mount.contains(document.activeElement); teardown?.(); @@ -1048,7 +1073,24 @@ function TerminalSessionViewport({ mount.focus({ preventScroll: true }); } }; - }, [cwd, environmentId, terminalId]); + // Preserve terminal state on Activity hides without fitting or resizing. + const cleanup = () => { + if (keptViewLifetime === null || !mount.isConnected) { + release(); + return; + } + visibleRef.current = false; + terminalRef.current?.setVisible(false); + parkedSurfaceRef.current = { + key: surfaceKey, + cleanup, + release, + cancelRelease: keptViewLifetime.hold(release), + }; + }; + return cleanup; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- The lifetime is fixed for a kept view. + }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { const terminal = terminalRef.current; diff --git a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx index 156a773e0211a..74c729f23a584 100644 --- a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx +++ b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx @@ -11,6 +11,7 @@ import { captureAssistantTextSelection, type AssistantCitationSourceAnchor, } from "~/lib/assistantTextSelection"; +import { usePortalContainer } from "~/lib/portalContainer"; import { observeSelectionActions, resolveSelectionActionPosition, @@ -27,6 +28,7 @@ export function AssistantSelectionToolbar({ threadRef: ScopedThreadRef; onCite: (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => boolean; }) { + const portalContainer = usePortalContainer(); const [selection, setSelection] = useState<{ citation: AssistantCitation; position: SelectionActionPoint; @@ -158,6 +160,6 @@ export function AssistantSelectionToolbar({
, - document.body, + portalContainer ?? document.body, ); } import { Button } from "../ui/button"; @@ -1103,6 +1105,7 @@ import { import { searchProviderSkills } from "../../providerSkillSearch"; import { useDelayedStatus } from "../../hooks/useDelayedStatus"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useElementState } from "../../hooks/useElementState"; import { useStableCallback } from "../../hooks/useStableCallback"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -1140,7 +1143,7 @@ const extendReplacementRangeForTrailingSpace = ( }; function useRestingComposerControlsLayout(host: HTMLDivElement | null, useControlsAsHost = false) { - const [controls, setControls] = useState(null); + const [controls, setControls] = useElementState(); const controlsRef = useRef(null); const attachControls = (element: HTMLDivElement | null) => { controlsRef.current = element; @@ -1596,7 +1599,7 @@ export interface ChatComposerProps { composerImagesRef: React.RefObject; composerFilesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; - composerRef: React.RefObject; + composerRef: React.Ref; onPageScrollKeyDown: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp: (key: string) => void; onPageScrollRelease: () => void; @@ -2294,6 +2297,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const noProviderAvailable = selectedProviderEntry === undefined && multipleModelSelections === null; + // A first connection attempt is connecting, not disconnected. + const environmentConnecting = environmentUnavailable?.connection.phase === "connecting"; + // Before the catalog arrives, every thread resolves to "no provider". Send + // stays blocked either way; only the chrome waits, keeping the picker with + // the thread's own selection instead of swapping in the setup button and + // back once the catalog lands. const providerCatalogPending = noProviderAvailable && !providerCatalogKnown; const showProviderUnavailable = noProviderAvailable && !providerCatalogPending; const providerSetupInstanceId = noProviderAvailable @@ -2426,7 +2435,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, ); - const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); + const [composerMenuAnchor, setComposerMenuAnchor] = useElementState(); const hasWrappedPrompt = useComposerMultilinePrompt(composerMenuAnchor); const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt; const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); @@ -2882,9 +2891,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerOwned: true, } satisfies Parameters[0]; const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput); - const [inlineRestingControlsHost, setInlineRestingControlsHost] = useState( - null, - ); + const [inlineRestingControlsHost, setInlineRestingControlsHost] = + useElementState(); const { controlsRef: restingComposerControlsRef, attachControls: attachRestingComposerControls, @@ -4826,7 +4834,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useLayoutEffect(() => { onRestingChange(isComposerResting); }, [isComposerResting, onRestingChange]); - const [restingActionsElement, setRestingActionsElement] = useState(null); + // The resting footer floats over the prompt row, so the row reserves its + // measured width. Wide actions like the compact-before-send chip vary too much for a fixed inset. + const [restingActionsElement, setRestingActionsElement] = useElementState(); const [restingActionsWidth, setRestingActionsWidth] = useState(null); useLayoutEffect(() => { if (!isComposerResting || !restingActionsElement) return; @@ -7081,6 +7091,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : prompt } cursor={composerCursor} + historyScope={`${routeThreadRef.environmentId}:${routeThreadRef.threadId}`} contextRecords={composerContextRecords} buildContextClipboardFragment={buildContextClipboardFragmentStable} importContextFragment={importContextFragmentStable} @@ -7147,9 +7158,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptHasText={false} isSendBusy={isSendBusy} sendDisabledReason={sendDisabledReason} - isConnecting={isConnecting} + isConnecting={isConnecting || environmentConnecting} isEnvironmentUnavailable={ - environmentUnavailable !== null || + (environmentUnavailable !== null && !environmentConnecting) || noProviderAvailable || projectSelectionRequired } @@ -7276,9 +7287,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptHasText={prompt.trim().length > 0} isSendBusy={isSendBusy} sendDisabledReason={sendDisabledReason} - isConnecting={isConnecting} + isConnecting={isConnecting || environmentConnecting} isEnvironmentUnavailable={ - environmentUnavailable !== null || + (environmentUnavailable !== null && !environmentConnecting) || noProviderAvailable || projectSelectionRequired } diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index d74c5a694dc08..7c6854629735a 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -189,6 +189,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ {...pointerFocusProps} disabled={ !canOperateThread || + isConnecting || isEnvironmentUnavailable || pendingAction.isResponding || (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance) @@ -214,7 +215,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ {...pointerFocusProps} disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} > - {isConnecting || isSendBusy ? "Sending..." : "Refine"} + {isSendBusy ? "Sending..." : "Refine"} ); } @@ -227,7 +228,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ {...pointerFocusProps} disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} > - {isConnecting || isSendBusy ? "Sending..." : "Implement"} + {isSendBusy ? "Sending..." : "Implement"}