From 5f81c3a06960d328eec0666e636c9bfe26bcf5b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 18:47:35 -0700 Subject: [PATCH 01/20] Refactor provider Effect services Co-authored-by: codex --- .../providerService.integration.test.ts | 46 +- .../src/provider/Drivers/ClaudeDriver.ts | 16 +- .../src/provider/Drivers/CodexDriver.ts | 16 +- .../src/provider/Drivers/CursorDriver.ts | 16 +- .../server/src/provider/Drivers/GrokDriver.ts | 16 +- .../src/provider/Drivers/OpenCodeDriver.ts | 29 +- .../src/provider/Layers/CodexAdapter.test.ts | 39 +- .../src/provider/Layers/CursorAdapter.ts | 4 +- .../server/src/provider/Layers/GrokAdapter.ts | 4 +- .../provider/Layers/OpenCodeAdapter.test.ts | 87 +- .../provider/Layers/OpenCodeProvider.test.ts | 26 +- .../Layers/ProviderAdapterRegistry.ts | 117 -- .../provider/Layers/ProviderEventLoggers.ts | 89 +- .../ProviderInstanceRegistryHydration.ts | 28 +- .../src/provider/Layers/ProviderRegistry.ts | 698 ---------- .../src/provider/Layers/ProviderService.ts | 1099 +--------------- .../Layers/ProviderSessionDirectory.ts | 203 +-- .../ProviderAdapterRegistry.test.ts | 36 +- .../src/provider/ProviderAdapterRegistry.ts | 150 +++ .../src/provider/ProviderEventLoggers.ts | 79 ++ ...st.ts => ProviderInstanceRegistry.test.ts} | 62 +- ...tryLive.ts => ProviderInstanceRegistry.ts} | 101 +- .../{Layers => }/ProviderRegistry.test.ts | 96 +- apps/server/src/provider/ProviderRegistry.ts | 741 +++++++++++ .../{Layers => }/ProviderService.test.ts | 61 +- apps/server/src/provider/ProviderService.ts | 1155 +++++++++++++++++ .../ProviderSessionDirectory.test.ts | 25 +- .../src/provider/ProviderSessionDirectory.ts | 244 ++++ .../ProviderSessionReaper.test.ts | 67 +- .../{Layers => }/ProviderSessionReaper.ts | 46 +- .../Services/ProviderAdapterRegistry.ts | 103 +- .../Services/ProviderInstanceRegistry.ts | 87 -- .../ProviderInstanceRegistryMutator.ts | 52 - .../src/provider/Services/ProviderRegistry.ts | 83 +- .../src/provider/Services/ProviderService.ts | 124 +- .../Services/ProviderSessionDirectory.ts | 70 - .../Services/ProviderSessionReaper.ts | 15 - apps/server/src/provider/opencodeRuntime.ts | 140 +- .../providerMaintenanceRunner.test.ts | 8 +- .../src/provider/providerMaintenanceRunner.ts | 54 +- .../testUtils/providerAdapterRegistryMock.ts | 31 +- .../testUtils/providerRegistryMock.ts | 6 +- apps/server/src/server.test.ts | 2 +- apps/server/src/server.ts | 32 +- apps/server/src/serverRuntimeStartup.ts | 2 +- .../OpenCodeTextGeneration.test.ts | 6 +- .../src/textGeneration/TextGeneration.test.ts | 2 +- .../src/textGeneration/TextGeneration.ts | 2 +- apps/server/src/ws.ts | 2 +- .../no-manual-effect-runtime-in-tests.ts | 4 +- 50 files changed, 3005 insertions(+), 3216 deletions(-) delete mode 100644 apps/server/src/provider/Layers/ProviderAdapterRegistry.ts delete mode 100644 apps/server/src/provider/Layers/ProviderRegistry.ts rename apps/server/src/provider/{Layers => }/ProviderAdapterRegistry.test.ts (82%) create mode 100644 apps/server/src/provider/ProviderAdapterRegistry.ts create mode 100644 apps/server/src/provider/ProviderEventLoggers.ts rename apps/server/src/provider/{Layers/ProviderInstanceRegistryLive.test.ts => ProviderInstanceRegistry.test.ts} (89%) rename apps/server/src/provider/{Layers/ProviderInstanceRegistryLive.ts => ProviderInstanceRegistry.ts} (83%) rename apps/server/src/provider/{Layers => }/ProviderRegistry.test.ts (96%) create mode 100644 apps/server/src/provider/ProviderRegistry.ts rename apps/server/src/provider/{Layers => }/ProviderService.test.ts (97%) create mode 100644 apps/server/src/provider/ProviderService.ts rename apps/server/src/provider/{Layers => }/ProviderSessionDirectory.test.ts (91%) create mode 100644 apps/server/src/provider/ProviderSessionDirectory.ts rename apps/server/src/provider/{Layers => }/ProviderSessionReaper.test.ts (89%) rename apps/server/src/provider/{Layers => }/ProviderSessionReaper.ts (77%) delete mode 100644 apps/server/src/provider/Services/ProviderInstanceRegistry.ts delete mode 100644 apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts delete mode 100644 apps/server/src/provider/Services/ProviderSessionDirectory.ts delete mode 100644 apps/server/src/provider/Services/ProviderSessionReaper.ts diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index e703af4b1f4..76522c12e1a 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -10,20 +10,13 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; -import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; +import * as ProviderAdapterRegistry from "../src/provider/ProviderAdapterRegistry.ts"; import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; -import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; -import { - NoOpProviderEventLoggers, - ProviderEventLoggers, -} from "../src/provider/Layers/ProviderEventLoggers.ts"; -import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; -import { - ProviderService, - type ProviderServiceShape, -} from "../src/provider/Services/ProviderService.ts"; -import { ServerSettingsService } from "../src/serverSettings.ts"; -import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; +import * as ProviderSessionDirectory from "../src/provider/ProviderSessionDirectory.ts"; +import * as ProviderEventLoggers from "../src/provider/ProviderEventLoggers.ts"; +import * as ProviderService from "../src/provider/ProviderService.ts"; +import * as ServerSettings from "../src/serverSettings.ts"; +import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; @@ -51,7 +44,7 @@ const makeWorkspaceDirectory = Effect.gen(function* () { interface IntegrationFixture { readonly cwd: string; readonly harness: TestProviderAdapterHarness; - readonly layer: Layer.Layer; + readonly layer: Layer.Layer; } const makeIntegrationFixture = Effect.gen(function* () { @@ -62,19 +55,24 @@ const makeIntegrationFixture = Effect.gen(function* () { [ProviderDriverKind.make("codex")]: harness.adapter, }); - const directoryLayer = ProviderSessionDirectoryLive.pipe( + const directoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(ProviderSessionRuntime.layer), ); const shared = Layer.mergeAll( directoryLayer, - Layer.succeed(ProviderAdapterRegistry, registry), - ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry), + ServerSettings.ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), AnalyticsService.layerTest, - Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), ).pipe(Layer.provide(SqlitePersistenceMemory)); - const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); + const layer = Layer.effect(ProviderService.ProviderService, ProviderService.make()).pipe( + Layer.provide(shared), + ); return { cwd, @@ -105,7 +103,7 @@ const collectEventsDuring = ( }); const runTurn = (input: { - readonly provider: ProviderServiceShape; + readonly provider: ProviderService.ProviderService["Service"]; readonly harness: TestProviderAdapterHarness; readonly threadId: ThreadId; readonly userText: string; @@ -129,7 +127,7 @@ it.live("replays typed runtime fixture events", () => const fixture = yield* makeIntegrationFixture; yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-typed"), { threadId: ThreadId.make("thread-integration-typed"), provider: ProviderDriverKind.make("codex"), @@ -166,7 +164,7 @@ it.live("replays file-changing fixture turn events", () => const { writeFileString } = yield* FileSystem.FileSystem; yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-tools"), { threadId: ThreadId.make("thread-integration-tools"), provider: ProviderDriverKind.make("codex"), @@ -203,7 +201,7 @@ it.live("runs multi-turn tool/approval flow", () => const { writeFileString } = yield* FileSystem.FileSystem; yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-multi"), { threadId: ThreadId.make("thread-integration-multi"), provider: ProviderDriverKind.make("codex"), @@ -255,7 +253,7 @@ it.live("rolls back provider conversation state only", () => const { writeFileString, readFileString } = yield* FileSystem.FileSystem; yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-rollback"), { threadId: ThreadId.make("thread-integration-rollback"), provider: ProviderDriverKind.make("codex"), diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f2b04b3a282..349f4f9c1d8 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -24,8 +24,8 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { @@ -33,7 +33,7 @@ import { makePendingClaudeProvider, probeClaudeCapabilities, } from "../Layers/ClaudeProvider.ts"; -import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, @@ -87,9 +87,9 @@ export type ClaudeDriverEnv = | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path - | ProviderEventLoggers - | ServerConfig - | ServerSettingsService; + | ProviderEventLoggers.ProviderEventLoggers + | ServerConfig.ServerConfig + | ServerSettings.ServerSettingsService; const withInstanceIdentity = (input: { @@ -120,8 +120,8 @@ export const ClaudeDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; - const serverSettings = yield* ServerSettingsService; - const eventLoggers = yield* ProviderEventLoggers; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..ff0df268b73 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -32,12 +32,12 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; -import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; @@ -79,9 +79,9 @@ export type CodexDriverEnv = | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path - | ProviderEventLoggers - | ServerConfig - | ServerSettingsService; + | ProviderEventLoggers.ProviderEventLoggers + | ServerConfig.ServerConfig + | ServerSettings.ServerSettingsService; /** * Stamp instance identity onto a `ServerProvider` snapshot produced by the @@ -117,8 +117,8 @@ export const CodexDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; - const serverSettings = yield* ServerSettingsService; - const eventLoggers = yield* ProviderEventLoggers; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index c394a7d1b43..023d18cdf7a 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -21,8 +21,8 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; @@ -31,7 +31,7 @@ import { checkCursorProviderStatus, enrichCursorSnapshot, } from "../Layers/CursorProvider.ts"; -import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, @@ -70,9 +70,9 @@ export type CursorDriverEnv = | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path - | ProviderEventLoggers - | ServerConfig - | ServerSettingsService; + | ProviderEventLoggers.ProviderEventLoggers + | ServerConfig.ServerConfig + | ServerSettings.ServerSettingsService; const withInstanceIdentity = (input: { @@ -104,8 +104,8 @@ export const CursorDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; - const serverSettings = yield* ServerSettingsService; - const eventLoggers = yield* ProviderEventLoggers; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index d855d1a4515..18b706b600e 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -8,8 +8,8 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeGrokAdapter } from "../Layers/GrokAdapter.ts"; @@ -18,7 +18,7 @@ import { checkGrokProviderStatus, enrichGrokSnapshot, } from "../Layers/GrokProvider.ts"; -import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, @@ -54,9 +54,9 @@ export type GrokDriverEnv = | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path - | ProviderEventLoggers - | ServerConfig - | ServerSettingsService; + | ProviderEventLoggers.ProviderEventLoggers + | ServerConfig.ServerConfig + | ServerSettings.ServerSettingsService; const withInstanceIdentity = (input: { @@ -86,8 +86,8 @@ export const GrokDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; - const serverSettings = yield* ServerSettingsService; - const eventLoggers = yield* ProviderEventLoggers; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 6342d176590..cb7626cf82b 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -23,17 +23,17 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { checkOpenCodeProviderStatus, makePendingOpenCodeProvider, } from "../Layers/OpenCodeProvider.ts"; -import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { OpenCodeRuntime } from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -82,11 +82,11 @@ export type OpenCodeDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient - | OpenCodeRuntime + | OpenCodeRuntime.OpenCodeRuntime | Path.Path - | ProviderEventLoggers - | ServerConfig - | ServerSettingsService; + | ProviderEventLoggers.ProviderEventLoggers + | ServerConfig.ServerConfig + | ServerSettings.ServerSettingsService; const withInstanceIdentity = (input: { @@ -114,11 +114,11 @@ export const OpenCodeDriver: ProviderDriver defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { - const openCodeRuntime = yield* OpenCodeRuntime; - const serverConfig = yield* ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; + const serverConfig = yield* ServerConfig.ServerConfig; const httpClient = yield* HttpClient.HttpClient; - const serverSettings = yield* ServerSettingsService; - const eventLoggers = yield* ProviderEventLoggers; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -147,7 +147,10 @@ export const OpenCodeDriver: ProviderDriver effectiveConfig, serverConfig.cwd, processEnv, - ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); + ).pipe( + Effect.map(stampIdentity), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>( diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 515a7c6fcbb..c47b86fa967 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -34,11 +34,11 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as CodexErrors from "effect-codex-app-server/errors"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import * as ProviderSessionDirectory from "../ProviderSessionDirectory.ts"; import { type CodexSessionRuntimeOptions, type CodexSessionRuntimeSendTurnInput, @@ -211,14 +211,17 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea }; } -const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { - upsert: () => Effect.void, - getProvider: () => - Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), - getBinding: () => Effect.succeed(Option.none()), - listThreadIds: () => Effect.succeed([]), - listBindings: () => Effect.succeed([]), -}); +const providerSessionDirectoryTestLayer = Layer.succeed( + ProviderSessionDirectory.ProviderSessionDirectory, + { + upsert: () => Effect.void, + getProvider: () => + Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), + }, +); const validationRuntimeFactory = makeRuntimeFactory(); const validationLayer = it.layer( @@ -232,7 +235,7 @@ const validationLayer = it.layer( }), ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -301,7 +304,7 @@ const sessionErrorLayer = it.layer( }), ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -373,7 +376,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ); @@ -427,7 +430,7 @@ const lifecycleLayer = it.layer( }), ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -1100,7 +1103,7 @@ const scopedLifecycleLayer = it.layer( }), ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -1144,7 +1147,7 @@ const scopedFailureLayer = it.layer( }), ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -1196,7 +1199,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => }), ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 9760b2f81fb..c0714873762 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -41,7 +41,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerConfig } from "../../config.ts"; +import * as Config from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, @@ -319,7 +319,7 @@ export function makeCursorAdapter( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverConfig = yield* Effect.service(ServerConfig); + const serverConfig = yield* Effect.service(Config.ServerConfig); const crypto = yield* Crypto.Crypto; const nativeEventLogger = options?.nativeEventLogger ?? diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 40f425cbaa1..c26e094aadd 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -32,7 +32,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerConfig } from "../../config.ts"; +import * as Config from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, @@ -176,7 +176,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverConfig = yield* Effect.service(ServerConfig); + const serverConfig = yield* Effect.service(Config.ServerConfig); const crypto = yield* Crypto.Crypto; const nativeEventLogger = options?.nativeEventLogger ?? diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index d0475e25284..83efe1f2e1f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -20,15 +20,11 @@ import { ThreadId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import * as Config from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as ProviderSessionDirectory from "../ProviderSessionDirectory.ts"; import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { appendOpenCodeAssistantTextDelta, makeOpenCodeAdapter, @@ -79,7 +75,7 @@ const runtimeMock = { }, }; -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { startOpenCodeServerProcess: ({ binaryPath }) => Effect.gen(function* () { runtimeMock.state.startCalls.push(binaryPath); @@ -167,10 +163,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { })(), }), }, - }) as unknown as ReturnType, + }) as unknown as ReturnType< + OpenCodeRuntime.OpenCodeRuntime["Service"]["createOpenCodeSdkClient"] + >, loadOpenCodeInventory: () => Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "loadOpenCodeInventory", detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", cause: null, @@ -178,14 +176,17 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ), }; -const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { - upsert: () => Effect.void, - getProvider: () => - Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), - getBinding: () => Effect.succeed(Option.none()), - listThreadIds: () => Effect.succeed([]), - listBindings: () => Effect.succeed([]), -}); +const providerSessionDirectoryTestLayer = Layer.succeed( + ProviderSessionDirectory.ProviderSessionDirectory, + { + upsert: () => Effect.void, + getProvider: () => + Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), + }, +); // The adapter now receives its settings as a plain argument (the old design // read from `ServerSettingsService` internally). The test-only @@ -203,10 +204,10 @@ const OpenCodeAdapterTestLayer = Layer.effect( OpenCodeAdapter, makeOpenCodeAdapter(openCodeAdapterTestSettings), ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(Config.ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge( - ServerSettingsService.layerTest({ + ServerSettings.ServerSettingsService.layerTest({ providers: { opencode: { binaryPath: "fake-opencode", @@ -334,9 +335,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { OpenCodeAdapter, makeOpenCodeAdapter(openCodeAdapterTestSettings), ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), + ), + Layer.provideMerge(Config.ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ); @@ -480,9 +483,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { OpenCodeAdapter, makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(Config.ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ); @@ -527,9 +530,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { OpenCodeAdapter, makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(Config.ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ); @@ -569,9 +572,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { OpenCodeAdapter, makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(Config.ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ); @@ -792,10 +795,12 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { nativeEventLogger, }), ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge( - ServerSettingsService.layerTest({ + Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), + ), + Layer.provideMerge(Config.ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettings.ServerSettingsService.layerTest({ providers: { opencode: { binaryPath: "fake-opencode", @@ -874,10 +879,12 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { nativeEventLogger, }), ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge( - ServerSettingsService.layerTest({ + Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), + ), + Layer.provideMerge(Config.ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettings.ServerSettingsService.layerTest({ providers: { opencode: { binaryPath: "fake-opencode", diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index b0e785512dc..f754d4ab9b2 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -8,14 +8,10 @@ import * as Schema from "effect/Schema"; import { beforeEach } from "vite-plus/test"; import { OpenCodeSettings } from "@t3tools/contracts"; -import { ServerConfig } from "../../config.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "../opencodeRuntime.ts"; +import * as Config from "../../config.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; -import type { OpenCodeInventory } from "../opencodeRuntime.ts"; + const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; @@ -52,7 +48,7 @@ const runtimeMock = { }, }; -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { startOpenCodeServerProcess: () => Effect.succeed({ url: "http://127.0.0.1:4301", @@ -76,7 +72,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { runOpenCodeCommand: () => runtimeMock.state.runVersionError ? Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "runOpenCodeCommand", detail: runtimeMock.state.runVersionError.message, cause: runtimeMock.state.runVersionError, @@ -84,25 +80,27 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ) : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), createOpenCodeSdkClient: () => - ({}) as unknown as ReturnType, + ({}) as unknown as ReturnType< + OpenCodeRuntime.OpenCodeRuntime["Service"]["createOpenCodeSdkClient"] + >, loadOpenCodeInventory: () => runtimeMock.state.inventoryError ? Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "loadOpenCodeInventory", detail: runtimeMock.state.inventoryError.message, cause: runtimeMock.state.inventoryError, }), ) - : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), + : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory), }; beforeEach(() => { runtimeMock.reset(); }); -const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), +const testLayer = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( + Layer.provideMerge(Config.ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts deleted file mode 100644 index b492399b10b..00000000000 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * ProviderAdapterRegistryLive — facade over `ProviderInstanceRegistry`. - * - * `ProviderAdapterRegistry` historically mapped one `ProviderDriverKind` to one - * adapter via the four `AdapterLive` singleton Layers. The per-instance - * refactor moved adapter construction inside each `ProviderDriver.create()`: - * adapters are now bundled on the `ProviderInstance` that the - * `ProviderInstanceRegistry` owns. - * - * This facade fulfills the `ProviderAdapterRegistryShape` contract by doing - * dynamic look-ups against `ProviderInstanceRegistry` on every call. That - * means settings-driven hot-reload shows up here automatically — adding a - * new instance via settings makes `getByInstance` resolve immediately - * without rebuilding the facade. - * - * @module ProviderAdapterRegistryLive - */ -import { - defaultInstanceIdForDriver, - ProviderInstanceId, - type ProviderDriverKind, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { ProviderUnsupportedError } from "../Errors.ts"; -import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; -import { - ProviderAdapterRegistry, - type ProviderAdapterRegistryShape, -} from "../Services/ProviderAdapterRegistry.ts"; - -const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () { - const registry = yield* ProviderInstanceRegistry; - - const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => - registry.getInstance(instanceId).pipe( - Effect.flatMap((instance) => - instance === undefined - ? Effect.fail( - new ProviderUnsupportedError({ - provider: instanceId, - }), - ) - : Effect.succeed(instance.adapter), - ), - ); - - const getInstanceInfo: ProviderAdapterRegistryShape["getInstanceInfo"] = (instanceId) => - registry.getInstance(instanceId).pipe( - Effect.flatMap((instance) => - instance === undefined - ? Effect.fail( - new ProviderUnsupportedError({ - provider: instanceId, - }), - ) - : Effect.succeed({ - instanceId: instance.instanceId, - driverKind: instance.driverKind, - displayName: instance.displayName, - accentColor: instance.accentColor, - enabled: instance.enabled, - continuationIdentity: instance.continuationIdentity, - }), - ), - ); - - const listInstances: ProviderAdapterRegistryShape["listInstances"] = () => - registry.listInstances.pipe( - Effect.map((instances) => instances.map((instance) => instance.instanceId)), - ); - - const listProviders: ProviderAdapterRegistryShape["listProviders"] = () => - registry.listInstances.pipe( - Effect.map((instances) => { - const kinds = new Set(); - for (const instance of instances) { - const defaultId = defaultInstanceIdForDriver(instance.driverKind); - if (instance.instanceId === defaultId) { - // Only the default-instance rows show up through the legacy - // shim — custom instances like `codex_personal` have no - // `ProviderDriverKind` equivalent. - kinds.add(instance.driverKind); - } - } - return Array.from(kinds); - }), - ); - - return { - getByInstance, - getInstanceInfo, - listInstances, - listProviders, - // Proxy directly — the facade has no state of its own; the instance - // registry already coalesces adds/removes/rebuilds into one emission. - streamChanges: registry.streamChanges, - subscribeChanges: registry.subscribeChanges, - } satisfies ProviderAdapterRegistryShape; -}); - -export const ProviderAdapterRegistryLive = Layer.effect( - ProviderAdapterRegistry, - makeProviderAdapterRegistry(), -); - -// Exposed for tests that want to build a facade over a pre-assembled -// `ProviderInstanceRegistry` without pulling in the whole boot graph. -export { makeProviderAdapterRegistry }; - -// Re-export for consumers that need the accessor shape. The service tag -// itself lives in `Services/ProviderAdapterRegistry.ts`. -export { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; -// Re-export for consumers (including tests) that construct a -// `ProviderInstanceId` before calling `getByInstance`. -export { ProviderInstanceId }; diff --git a/apps/server/src/provider/Layers/ProviderEventLoggers.ts b/apps/server/src/provider/Layers/ProviderEventLoggers.ts index 711aa6e76b6..37bc35ff958 100644 --- a/apps/server/src/provider/Layers/ProviderEventLoggers.ts +++ b/apps/server/src/provider/Layers/ProviderEventLoggers.ts @@ -1,85 +1,6 @@ -/** - * ProviderEventLoggers — single observability service that owns the two - * shared NDJSON streams the provider runtime writes: - * - * - `native` — provider-protocol events as the SDK emits them, written - * from inside each `Adapter` factory. - * - `canonical` — runtime events after `ProviderService` has normalized - * them onto `ProviderRuntimeEvent`. - * - * Why a service tag and not constructor options? - * - * - Adapters are now constructed *inside* drivers (`Driver.create()`), - * not at the boot Layer. There is no longer a single `makeAdapterLive(options)` - * call site where we can hand an `EventNdjsonLogger` in by hand. - * - Multiple driver instances per kind (`codex_personal`, `codex_work`) - * should share one underlying log writer per stream — opening N writers - * against the same rotating file would race the rotation logic. Owning - * the loggers on a single tag keeps that invariant intact. - * - Tests can swap one (or both) loggers with in-memory recorders by - * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of - * juggling per-Layer option threading. - * - * Both fields are optional. `makeEventNdjsonLogger` returns `undefined` when - * the target directory cannot be created; we forward that as `undefined` - * rather than failing the boot Layer, matching the previous best-effort - * behavior of `server.ts`. - * - * @module provider/Layers/ProviderEventLoggers - */ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; +// Compatibility shim for the intentionally excluded orchestration harness. +import * as Canonical from "../ProviderEventLoggers.ts"; -import { ServerConfig } from "../../config.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; - -export interface ProviderEventLoggersShape { - readonly native: EventNdjsonLogger | undefined; - readonly canonical: EventNdjsonLogger | undefined; -} - -/** - * Shared logger pair for native + canonical provider event streams. - * - * Service value is intentionally a struct of two optional loggers rather - * than two parallel tags. Construction site is one place - * (`ProviderEventLoggersLive`); consumers (drivers, `ProviderService`) read - * one tag and pluck the field they need. - */ -export class ProviderEventLoggers extends Context.Service< - ProviderEventLoggers, - ProviderEventLoggersShape ->()("t3/provider/Layers/ProviderEventLoggers") {} - -/** - * Constant value used by tests / boot layers that want to opt out of native - * + canonical logging entirely. Keeps the tag non-optional in the type - * system while letting the runtime treat absence as a no-op. - */ -export const NoOpProviderEventLoggers: ProviderEventLoggersShape = { - native: undefined, - canonical: undefined, -}; - -/** - * Live Layer that builds both loggers from `ServerConfig.providerEventLogPath`. - * If the directory create fails for either stream, the corresponding field - * is `undefined` and writes from that stream become no-ops downstream. - */ -export const ProviderEventLoggersLive = Layer.effect( - ProviderEventLoggers, - Effect.gen(function* () { - const { providerEventLogPath } = yield* ServerConfig; - const native = yield* makeEventNdjsonLogger(providerEventLogPath, { - stream: "native", - }); - const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, { - stream: "canonical", - }); - return { - native, - canonical, - } satisfies ProviderEventLoggersShape; - }), -); +export const ProviderEventLoggersLive = Canonical.layer; +export const ProviderEventLoggers = Canonical.ProviderEventLoggers; +export const NoOpProviderEventLoggers = Canonical.NoOpProviderEventLoggers; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 0fd88b4262a..a6d3b1e19ea 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -29,7 +29,7 @@ * ---------- * On layer build we: * 1. Read the current `ServerSettings` once and use it to seed the - * registry's initial state via `ProviderInstanceRegistryMutableLayer`. + * registry's initial state via `ProviderInstanceRegistry.mutableLayer`. * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that * subscribes to `ServerSettingsService.streamChanges` and calls * `ProviderInstanceRegistryMutator.reconcile` on every emission. @@ -51,11 +51,9 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import * as ServerSettingsModule from "../../serverSettings.ts"; import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts"; -import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; -import { ProviderInstanceRegistryMutator } from "../Services/ProviderInstanceRegistryMutator.ts"; -import { ProviderInstanceRegistryMutableLayer } from "./ProviderInstanceRegistryLive.ts"; +import * as ProviderInstanceRegistry from "../ProviderInstanceRegistry.ts"; /** * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. @@ -116,8 +114,8 @@ export const deriveProviderInstanceConfigMap = ( */ const SettingsWatcherLive = Layer.effectDiscard( Effect.gen(function* () { - const mutator = yield* ProviderInstanceRegistryMutator; - const serverSettings = yield* ServerSettingsService; + const mutator = yield* ProviderInstanceRegistry.ProviderInstanceRegistryMutator; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* serverSettings.streamChanges.pipe( Stream.runForEach((next) => mutator @@ -138,7 +136,7 @@ const SettingsWatcherLive = Layer.effectDiscard( * sync with subsequent `streamChanges` emissions. * * The Layer's two halves: - * - `ProviderInstanceRegistryMutableLayer` produces the registry + + * - `ProviderInstanceRegistry.mutableLayer` produces the registry + * mutator from the initial config map. Its scope owns every * per-instance child scope created during reconcile. * - `SettingsWatcherLive` consumes the mutator and runs a daemon fiber @@ -150,12 +148,12 @@ const SettingsWatcherLive = Layer.effectDiscard( * it, so the visibility leak is harmless in practice. */ export const ProviderInstanceRegistryHydrationLive: Layer.Layer< - ProviderInstanceRegistry, + ProviderInstanceRegistry.ProviderInstanceRegistry, never, - BuiltInDriversEnv | ServerSettingsService + BuiltInDriversEnv | ServerSettingsModule.ServerSettingsService > = Layer.unwrap( Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( Effect.orElseSucceed(() => undefined), ); @@ -164,11 +162,15 @@ export const ProviderInstanceRegistryHydrationLive: Layer.Layer< ? ({} as ProviderInstanceConfigMap) : deriveProviderInstanceConfigMap(initialSettings); - const mutableLayer = ProviderInstanceRegistryMutableLayer({ + const mutableLayer = ProviderInstanceRegistry.mutableLayer({ drivers: BUILT_IN_DRIVERS, configMap: initialConfigMap, }); return SettingsWatcherLive.pipe(Layer.provideMerge(mutableLayer)); }), -) as Layer.Layer; +) as Layer.Layer< + ProviderInstanceRegistry.ProviderInstanceRegistry, + never, + BuiltInDriversEnv | ServerSettingsModule.ServerSettingsService +>; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts deleted file mode 100644 index 2df63e53830..00000000000 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ /dev/null @@ -1,698 +0,0 @@ -/** - * ProviderRegistryLive — aggregates per-instance snapshot streams into a - * single materialized list. - * - * Historically this Layer composed four per-kind Live Layers - * (`CodexProviderLive`, `ClaudeProviderLive`, …) that each exposed a - * `ServerProviderShape`. Those Lives were deleted during the driver / - * instance refactor — every driver now carries its `snapshot: ServerProviderShape` - * bundled onto the `ProviderInstance` the registry produces. - * - * Each configured instance (including multi-instance setups like - * `codex_personal` + `codex_work`) contributes one `ProviderSnapshotSource`, - * keyed by `instanceId`. Instances whose driver is unavailable or whose - * config failed to decode are merged from `instanceRegistry.listUnavailable` - * as shadow snapshots so the UI can render their exact unavailable reason. - * - * Cache paths on disk are now keyed by `instanceId`. Because - * `defaultInstanceIdForDriver(kind) === kind` for built-in kinds, existing - * `.json` files remain the on-disk location for that driver's default - * instance. Identity-less legacy cache contents are ignored and replaced by - * the first live refresh. - * - * @module ProviderRegistryLive - */ -import { - defaultInstanceIdForDriver, - ProviderDriverKind, - type ProviderInstanceId, - type ServerProvider, - type ServerProviderUpdateState, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Equal from "effect/Equal"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as PubSub from "effect/PubSub"; -import * as Ref from "effect/Ref"; -import * as Stream from "effect/Stream"; -import * as Semaphore from "effect/Semaphore"; - -import { ServerConfig } from "../../config.ts"; -import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; -import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; -import { - hydrateCachedProvider, - isCachedProviderCorrelated, - orderProviderSnapshots, - readProviderStatusCache, - resolveProviderStatusCachePath, - writeProviderStatusCache, -} from "../providerStatusCache.ts"; -import type { ProviderInstance } from "../ProviderDriver.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; -import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; - -const loadProviders = ( - providerSources: ReadonlyArray, -): Effect.Effect> => - Effect.forEach( - providerSources, - (providerSource) => - providerSource.getSnapshot.pipe( - Effect.flatMap((snapshot) => correlateSnapshotWithSource(providerSource, snapshot)), - ), - { - concurrency: "unbounded", - }, - ); - -const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) => - makeManualOnlyProviderMaintenanceCapabilities({ - provider, - packageName: null, - }); - -const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => - (model.capabilities?.optionDescriptors?.length ?? 0) > 0; - -const mergeProviderModels = ( - previousModels: ReadonlyArray, - nextModels: ReadonlyArray, -): ReadonlyArray => { - if (nextModels.length === 0 && previousModels.length > 0) { - return previousModels; - } - - const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); - const mergedModels = nextModels.map((model) => { - const previousModel = previousBySlug.get(model.slug); - if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { - return model; - } - return { - ...model, - capabilities: previousModel.capabilities, - }; - }); - const nextSlugs = new Set(nextModels.map((model) => model.slug)); - return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]; -}; - -export const mergeProviderSnapshot = ( - previousProvider: ServerProvider | undefined, - nextProvider: ServerProvider, -): ServerProvider => - !previousProvider - ? nextProvider - : { - ...nextProvider, - models: mergeProviderModels(previousProvider.models, nextProvider.models), - }; - -export const mergeProviderSnapshots = ( - previousProviders: ReadonlyArray, - nextProviders: ReadonlyArray, -): ReadonlyArray => { - const mergedProviders = new Map( - previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), - ); - - for (const provider of nextProviders) { - mergedProviders.set( - snapshotInstanceKey(provider), - mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), - ); - } - - return orderProviderSnapshots([...mergedProviders.values()]); -}; - -export const selectProvidersByKind = ( - providers: ReadonlyArray, - providerKinds: ReadonlySet, -): ReadonlyArray => - providers.filter((provider) => providerKinds.has(provider.driver)); - -export const haveProvidersChanged = ( - previousProviders: ReadonlyArray, - nextProviders: ReadonlyArray, -): boolean => !Equal.equals(previousProviders, nextProviders); - -const correlateSnapshotWithSource = ( - source: ProviderSnapshotSource, - snapshot: ServerProvider, -): Effect.Effect => { - if (snapshot.instanceId !== source.instanceId) { - return Effect.die( - new Error( - `Provider snapshot instance mismatch: source '${source.instanceId}' emitted '${snapshot.instanceId}'.`, - ), - ); - } - if (snapshot.driver !== source.driverKind) { - return Effect.die( - new Error( - `Provider snapshot driver mismatch for instance '${source.instanceId}': source '${source.driverKind}' emitted '${snapshot.driver}'.`, - ), - ); - } - return Effect.succeed(snapshot); -}; - -/** - * Key a snapshot for aggregation and persistence. Snapshot sources - * must be correlated by instance id before reaching this map; missing - * identities are defects, not runtime routing fallbacks. - */ -const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { - return provider.instanceId; -}; - -// Project a live `ProviderInstance` into the aggregator's consumption -// shape. Each call re-captures the instance's `snapshot` closures, so -// after `ProviderInstanceRegistry` rebuilds an instance (e.g. because -// its settings changed), a fresh source rides the new PubSub instead -// of a closed one. -const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ - instanceId: instance.instanceId, - driverKind: instance.driverKind, - getSnapshot: instance.snapshot.getSnapshot, - refresh: instance.snapshot.refresh, - streamChanges: instance.snapshot.streamChanges, -}); - -export const ProviderRegistryLive = Layer.effect( - ProviderRegistry, - Effect.gen(function* () { - const instanceRegistry = yield* ProviderInstanceRegistry; - const config = yield* ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for - // coalesced updates across every instance. - const changesPubSub = yield* Effect.acquireRelease( - PubSub.unbounded>(), - PubSub.shutdown, - ); - - // Boot-only: hydrate `providersRef` from the on-disk per-instance - // cache so the UI has something to render during the first refresh. - // Instances added post-boot skip this path; their first entry in - // `providersRef` comes from the reactive `syncLiveSources` pass - // below. - const bootInstances = yield* instanceRegistry.listInstances; - const bootSources = bootInstances.map(buildSnapshotSource); - const fallbackProviders = yield* loadProviders(bootSources); - const fallbackByInstance = new Map(); - for (let index = 0; index < fallbackProviders.length; index++) { - const provider = fallbackProviders[index]; - const source = bootSources[index]; - if (provider === undefined || source === undefined) { - continue; - } - fallbackByInstance.set(source.instanceId, provider); - } - - const cachedProviders = yield* Effect.forEach( - bootSources, - (source) => - Effect.gen(function* () { - // One cache file per configured instance. For the default - // instance of a built-in kind the path equals `.json` — - // identical to the legacy filename. We still require the cache - // payload to carry matching instance id + driver kind; old - // identity-less payloads are discarded and the awaited refresh - // below repopulates the cache. - const filePath = yield* resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - instanceId: source.instanceId, - }).pipe(Effect.provideService(Path.Path, path)); - const fallbackProvider = fallbackByInstance.get(source.instanceId); - if (fallbackProvider === undefined) { - return undefined; - } - return yield* readProviderStatusCache(filePath).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.flatMap((cachedProvider) => { - if (cachedProvider === undefined) { - return Effect.void.pipe(Effect.as(undefined as ServerProvider | undefined)); - } - const correlation = { - cachedProvider, - fallbackProvider, - } as const; - if (!isCachedProviderCorrelated(correlation)) { - return Effect.logWarning("provider status cache identity mismatch, ignoring", { - path: filePath, - instanceId: source.instanceId, - cachedInstanceId: cachedProvider.instanceId ?? null, - driver: source.driverKind, - cachedDriver: cachedProvider.driver ?? null, - }).pipe(Effect.as(undefined as ServerProvider | undefined)); - } - return Effect.succeed(hydrateCachedProvider(correlation)); - }), - ); - }), - { concurrency: "unbounded" }, - ).pipe( - Effect.map((providers) => - orderProviderSnapshots( - providers.filter((provider): provider is ServerProvider => provider !== undefined), - ), - ), - ); - const providersRef = yield* Ref.make>(cachedProviders); - const maintenanceActionStatesRef = yield* Ref.make< - ReadonlyMap - >(new Map()); - - // Live-source registry — the dynamic counterpart to the boot-time - // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` - // reference is used for identity equality so "no-op" reconciles - // (settings unchanged) skip re-subscribing + re-probing. - const liveSubsRef = yield* Ref.make>( - new Map(), - ); - // Serialize `syncLiveSources` so a rapid burst of reconciles doesn't - // interleave two passes clobbering each other's fiber bookkeeping. - const syncSemaphore = yield* Semaphore.make(1); - - const getLiveSources: Effect.Effect> = Ref.get( - liveSubsRef, - ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource))); - - const persistProvider = (provider: ServerProvider) => - Effect.gen(function* () { - // Persist every instance — the file name is the instance id, so - // multi-instance setups (e.g. `codex_personal`, `codex_work`) each - // get their own cache. We resolve the path fresh so snapshots - // produced by newly-added instances post-boot still land on disk - // without the aggregator holding a stale `cachePathByInstance` - // entry. - const key = snapshotInstanceKey(provider); - const filePath = yield* resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - instanceId: key, - }).pipe(Effect.provideService(Path.Path, path)); - yield* writeProviderStatusCache({ filePath, provider }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.tapError(Effect.logError), - Effect.ignore, - ); - }); - - const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* ( - provider: ServerProvider, - ) { - const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef); - const updateState = maintenanceActionStates.get(provider.instanceId)?.update; - if (!updateState) { - const { updateState: _updateState, ...providerWithoutUpdateState } = provider; - return providerWithoutUpdateState; - } - return { - ...provider, - updateState, - }; - }); - - const upsertProviders = Effect.fn("upsertProviders")(function* ( - nextProviders: ReadonlyArray, - options?: { - readonly publish?: boolean; - readonly persist?: boolean; - readonly replace?: boolean; - }, - ) { - const nextProvidersWithUpdateState = yield* Effect.forEach( - nextProviders, - applyProviderUpdateState, - { - concurrency: "unbounded", - }, - ); - const [previousProviders, providers, providersToPersist] = yield* Ref.modify( - providersRef, - (previousProviders) => { - const mergedProviders = new Map( - previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), - ); - const updatedKeys = new Set(); - - for (const provider of nextProvidersWithUpdateState) { - const key = snapshotInstanceKey(provider); - updatedKeys.add(key); - mergedProviders.set( - key, - options?.replace === true - ? provider - : mergeProviderSnapshot(mergedProviders.get(key), provider), - ); - } - - const providers = orderProviderSnapshots([...mergedProviders.values()]); - const providersToPersist = providers.filter((provider) => - updatedKeys.has(snapshotInstanceKey(provider)), - ); - return [[previousProviders, providers, providersToPersist] as const, providers]; - }, - ); - - if (haveProvidersChanged(previousProviders, providers)) { - if (options?.persist !== false) { - yield* Effect.forEach(providersToPersist, persistProvider, { - concurrency: "unbounded", - discard: true, - }); - } - if (options?.publish !== false) { - yield* PubSub.publish(changesPubSub, providers); - } - } - - return providers; - }); - - const syncProvider = Effect.fn("syncProvider")(function* ( - provider: ServerProvider, - options?: { - readonly publish?: boolean; - }, - ) { - return yield* upsertProviders([provider], options); - }); - - const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( - function* (input: { - readonly instanceId: ProviderInstanceId; - readonly action: "update"; - readonly state: ServerProviderUpdateState | null; - }) { - yield* Ref.update(maintenanceActionStatesRef, (previous) => { - const previousActions = previous.get(input.instanceId); - const nextActions = { ...previousActions }; - if (input.state === null || input.state.status === "idle") { - delete nextActions[input.action]; - } else { - nextActions[input.action] = input.state; - } - - const next = new Map(previous); - if (Object.keys(nextActions).length === 0) { - next.delete(input.instanceId); - } else { - next.set(input.instanceId, nextActions); - } - return next; - }); - - const existingProviders = yield* Ref.get(providersRef); - const matchingProvider = existingProviders.find( - (candidate) => candidate.instanceId === input.instanceId, - ); - if (!matchingProvider) { - return existingProviders; - } - - const nextProvider = yield* applyProviderUpdateState(matchingProvider); - return yield* upsertProviders([nextProvider], { - persist: false, - }); - }, - ); - - const refreshOneSource = Effect.fn("refreshOneSource")(function* ( - providerSource: ProviderSnapshotSource, - ) { - return yield* providerSource.refresh.pipe( - Effect.flatMap((nextProvider) => - correlateSnapshotWithSource(providerSource, nextProvider).pipe( - Effect.flatMap(syncProvider), - ), - ), - ); - }); - - const refreshAll = Effect.fn("refreshAll")(function* () { - const sources = yield* getLiveSources; - return yield* Effect.forEach(sources, (source) => refreshOneSource(source), { - concurrency: "unbounded", - discard: true, - }).pipe(Effect.andThen(Ref.get(providersRef))); - }); - - const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) { - if (provider === undefined) { - return yield* refreshAll(); - } - // Kind-scoped refreshes target the default instance for that driver. - const defaultInstanceId = defaultInstanceIdForDriver(provider); - const sources = yield* getLiveSources; - const providerSource = sources.find( - (candidate) => candidate.instanceId === defaultInstanceId, - ); - if (!providerSource) { - return yield* Ref.get(providersRef); - } - return yield* refreshOneSource(providerSource); - }); - - const refreshInstance = Effect.fn("refreshInstance")(function* ( - instanceId: ProviderInstanceId, - ) { - const sources = yield* getLiveSources; - const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); - if (!providerSource) { - return yield* Ref.get(providersRef); - } - return yield* refreshOneSource(providerSource); - }); - - const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( - "getProviderMaintenanceCapabilitiesForInstance", - )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) { - const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find( - (candidate) => candidate.instanceId === instanceId, - ); - return ( - instance?.snapshot.maintenanceCapabilities ?? - makeManualProviderMaintenanceCapabilities(provider) - ); - }); - - /** - * Diff the aggregator's live-source set against the current - * `ProviderInstanceRegistry` and: - * - subscribe to each newly-added or rebuilt instance's - * `streamChanges` (so periodic + enrichment refreshes land in - * `providersRef`); - * - read each newly-added/rebuilt instance's current snapshot after - * subscribing, closing the race with its independently-running - * background startup probe; - * - prune `providersRef` of instances that no longer exist. - * - * Provider refreshes are owned by each managed provider and never run - * on this layer's construction path. Consumers see cached or pending - * snapshots immediately, then receive live probe results through the - * already-attached change stream. - * - * Per-instance subscription fibers are not tracked explicitly. When - * a rebuilt instance's old child scope closes, its PubSub shuts - * down and our `Stream.runForEach` fiber exits naturally. - */ - const syncLiveSources = syncSemaphore.withPermits(1)( - Effect.gen(function* () { - const instances = yield* instanceRegistry.listInstances; - const unavailableProviders = yield* instanceRegistry.listUnavailable; - const nextByInstance = new Map( - instances.map((instance) => [instance.instanceId, instance] as const), - ); - const knownInstanceIds = new Set(nextByInstance.keys()); - for (const provider of unavailableProviders) { - knownInstanceIds.add(snapshotInstanceKey(provider)); - } - const previousSubs = yield* Ref.get(liveSubsRef); - - // Carry over subscriptions for instances whose identity is - // unchanged (reconcile treated them as no-op). Instances that - // disappeared, or were rebuilt with a different reference, - // fall through to the "newly-added" branch below. - const carriedOver = new Map(); - for (const [instanceId, previousInstance] of previousSubs) { - const nextInstance = nextByInstance.get(instanceId); - if (nextInstance !== undefined && nextInstance === previousInstance) { - carriedOver.set(instanceId, previousInstance); - } - } - - // Collect new/rebuilt instances in `nextByInstance` insertion - // order (which preserves settings-author order). - const newlyAdded: Array = []; - for (const [instanceId, instance] of nextByInstance) { - if (carriedOver.has(instanceId)) { - continue; - } - newlyAdded.push([instanceId, instance] as const); - } - - // Fork long-lived subscriptions to each new/rebuilt instance's - // change stream before reading its current snapshot. If the - // driver's own initial probe finishes during this sync, either - // the current read or the active subscriber observes the result. - for (const [, instance] of newlyAdded) { - const source = buildSnapshotSource(instance); - yield* Stream.runForEach(source.streamChanges, (provider) => - correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)), - ).pipe(Effect.forkScoped); - } - yield* Effect.yieldNow; - - // Snapshot current state without starting a probe. Managed providers - // launch their startup refresh independently, so this closes the - // subscription race without putting external work on the registry - // or HTTP server construction path. - yield* Effect.forEach( - newlyAdded, - ([, instance]) => - Effect.gen(function* () { - const source = buildSnapshotSource(instance); - const provider = yield* source.getSnapshot; - yield* correlateSnapshotWithSource(source, provider).pipe( - Effect.flatMap(syncProvider), - ); - }).pipe(Effect.ignoreCause({ log: true })), - { concurrency: "unbounded", discard: true }, - ); - yield* upsertProviders(unavailableProviders, { - persist: false, - replace: true, - }); - - const nextSubs = new Map(carriedOver); - for (const [instanceId, instance] of newlyAdded) { - nextSubs.set(instanceId, instance); - } - yield* Ref.set(liveSubsRef, nextSubs); - - // Drop aggregator state for instances that have disappeared — - // otherwise the UI would keep rendering ghosts. - const [previousProviders, providers] = yield* Ref.modify( - providersRef, - (previousProviders) => { - const providers = orderProviderSnapshots( - previousProviders.filter((provider) => - knownInstanceIds.has(snapshotInstanceKey(provider)), - ), - ); - return [[previousProviders, providers] as const, providers]; - }, - ); - if (haveProvidersChanged(previousProviders, providers)) { - yield* PubSub.publish(changesPubSub, providers); - } - yield* Ref.update(maintenanceActionStatesRef, (previous) => { - const next = new Map(previous); - for (const instanceId of previous.keys()) { - if (!knownInstanceIds.has(instanceId)) { - next.delete(instanceId); - } - } - return next; - }); - }), - ); - const syncLiveSourcesAndContinue = syncLiveSources.pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.interrupt; - } - return Effect.logError( - "provider registry instance sync failed; keeping subscription alive", - { - cause: Cause.pretty(cause), - }, - ); - }), - ); - - // Seed `providersRef` with the boot-time fallback snapshots so - // consumers calling `getProviders` immediately after layer build see - // a populated list — even before the first `syncLiveSources` refresh - // resolves. Cached snapshots (already in `providersRef`) merge with - // these via `upsertProviders` so on-disk state wins where present - // and pending fallbacks fill the gaps. - yield* upsertProviders(fallbackProviders, { publish: false }); - // Subscribe to registry mutations BEFORE running the initial sync. - // `subscribeChanges` acquires the dequeue synchronously in this - // fibre; the subscription is active the instant this `yield*` - // returns. Forking the consumer loop later cannot lose a publish - // because no publish can reach a not-yet-subscribed dequeue. - // - // (Contrast with the pre-fix code that did - // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. - // `Stream.fromPubSub` defers `PubSub.subscribe` to stream start, - // and `forkScoped` only schedules the fibre — so a reconcile that - // published between "fibre scheduled" and "fibre starts running" - // was dropped, which made any settings change that replaced an - // instance never propagate to the aggregator's `providersRef`.) - // Subscribe to registry mutations BEFORE running the initial sync. - // `subscribeChanges` acquires the `PubSub.Subscription` synchronously - // in this fibre; the subscription is registered with the PubSub the - // instant this `yield*` returns, so any subsequent publish is - // buffered in the subscription regardless of when the consumer - // fibre below actually starts running. - // - // (Contrast with the pre-fix code that did - // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. - // `instanceRegistry.streamChanges` is `Stream.fromPubSub(changes)`, - // which defers `PubSub.subscribe` to stream start. `forkScoped` only - // schedules the consumer fibre — so a reconcile that published - // between "fibre scheduled" and "fibre starts running + subscribes" - // was dropped, which made any settings change that replaced an - // instance never propagate to the aggregator's `providersRef`.) - const instanceChanges = yield* instanceRegistry.subscribeChanges; - // Initial sync attaches subscriptions and snapshots current state for - // every instance present at boot. Provider probes are already running in - // their managed background fibers and never block this layer. - yield* syncLiveSources; - // React to registry mutations — instance added / removed / rebuilt. - // `Stream.fromSubscription` builds a stream over the pre-acquired - // subscription rather than subscribing on stream start, which is - // what closes the race. - yield* Stream.runForEach( - Stream.fromSubscription(instanceChanges), - () => syncLiveSourcesAndContinue, - ).pipe(Effect.forkScoped); - - const recoverRefreshFailure = Effect.fn("recoverRefreshFailure")(function* ( - cause: Cause.Cause, - ) { - if (Cause.hasInterruptsOnly(cause)) { - return yield* Effect.interrupt; - } - yield* Effect.logError("provider registry refresh failed; preserving cached providers", { - cause: Cause.pretty(cause), - }); - return yield* Ref.get(providersRef); - }); - - return { - getProviders: Ref.get(providersRef), - refresh: (provider?: ProviderDriverKind) => - refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), - refreshInstance: (instanceId: ProviderInstanceId) => - refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), - getProviderMaintenanceCapabilitiesForInstance, - setProviderMaintenanceActionState, - get streamChanges() { - return Stream.fromPubSub(changesPubSub); - }, - } satisfies ProviderRegistryShape; - }), -); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2eaaeb8ce3c..93d3d246990 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1,1098 +1,11 @@ -/** - * ProviderServiceLive - Cross-provider orchestration layer. - * - * Routes validated transport/API calls to provider adapters through - * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a - * unified provider event stream for subscribers. - * - * It does not implement provider protocol details (adapter concern). - * - * @module ProviderServiceLive - */ -import { - ModelSelection, - NonNegativeInt, - ThreadId, - ProviderInterruptTurnInput, - ProviderRespondToRequestInput, - ProviderRespondToUserInputInput, - ProviderSendTurnInput, - ProviderSessionStartInput, - ProviderStopSessionInput, - type ProviderInstanceId, - type ProviderDriverKind, - type ProviderRuntimeEvent, - type ProviderSession, -} from "@t3tools/contracts"; -import { causeErrorTag } from "@t3tools/shared/observability"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; +// Compatibility shim for the intentionally excluded orchestration harness. import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as PubSub from "effect/PubSub"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; -import * as Stream from "effect/Stream"; -import { - increment, - providerMetricAttributes, - providerRuntimeEventsTotal, - providerSessionsTotal, - providerTurnDuration, - providerTurnsTotal, - providerTurnMetricAttributes, - withMetrics, -} from "../../observability/Metrics.ts"; -import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; -import * as ProviderService from "../Services/ProviderService.ts"; -import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts"; -import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; -import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; -import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; -const isModelSelection = Schema.is(ModelSelection); +import * as ProviderService from "../ProviderService.ts"; -/** - * Hook for tests that want to override the canonical event logger pulled - * from `ProviderEventLoggers`. Production wiring leaves this undefined and - * reads the logger off the tag. - */ -export interface ProviderServiceLiveOptions { - readonly canonicalEventLogger?: EventNdjsonLogger; -} +export type ProviderServiceLiveOptions = ProviderService.ProviderServiceOptions; -type ProviderServiceMethod = - ProviderService.ProviderService["Service"][Name]; +export const ProviderServiceLive = ProviderService.layer; -const ProviderRollbackConversationInput = Schema.Struct({ - threadId: ThreadId, - numTurns: NonNegativeInt, -}); - -function toValidationError( - operation: string, - issue: string, - cause?: unknown, -): ProviderValidationError { - return new ProviderValidationError({ - operation, - issue, - ...(cause !== undefined ? { cause } : {}), - }); -} - -const decodeInputOrValidationError = (input: { - readonly operation: string; - readonly schema: S; - readonly payload: unknown; -}) => { - const decodeProviderRequestInput = Schema.decodeUnknownEffect(input.schema); - return decodeProviderRequestInput(input.payload).pipe( - Effect.mapError( - (schemaError) => - new ProviderValidationError({ - operation: input.operation, - issue: SchemaIssue.makeFormatterDefault()(schemaError.issue), - cause: schemaError, - }), - ), - ); -}; - -function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "stopped" | "error" { - switch (session.status) { - case "connecting": - return "starting"; - case "error": - return "error"; - case "closed": - return "stopped"; - case "ready": - case "running": - default: - return "running"; - } -} - -function toRuntimePayloadFromSession( - session: ProviderSession, - extra?: { - readonly modelSelection?: unknown; - readonly lastRuntimeEvent?: string; - readonly lastRuntimeEventAt?: string; - }, -): Record { - return { - cwd: session.cwd ?? null, - model: session.model ?? null, - activeTurnId: session.activeTurnId ?? null, - lastError: session.lastError ?? null, - ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), - ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), - ...(extra?.lastRuntimeEventAt !== undefined - ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } - : {}), - }; -} - -function readPersistedModelSelection( - runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], -): ModelSelection | undefined { - if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { - return undefined; - } - const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined; - return isModelSelection(raw) ? raw : undefined; -} - -function readPersistedCwd( - runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], -): string | undefined { - if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { - return undefined; - } - const rawCwd = "cwd" in runtimePayload ? runtimePayload.cwd : undefined; - if (typeof rawCwd !== "string") return undefined; - const trimmed = rawCwd.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -const dieOnMissingBindingInstanceId = ( - operation: string, - payload: { - readonly providerInstanceId?: ProviderInstanceId | undefined; - readonly provider?: ProviderDriverKind | undefined; - }, -): ProviderInstanceId => { - if (payload.providerInstanceId !== undefined) { - return payload.providerInstanceId; - } - throw new Error( - payload.provider - ? `${operation}: provider instance id is required for provider '${payload.provider}'.` - : `${operation}: provider instance id is required.`, - ); -}; - -const correlateRuntimeEventWithInstance = ( - source: { - readonly instanceId: ProviderInstanceId; - readonly provider: ProviderDriverKind; - }, - event: ProviderRuntimeEvent, -): ProviderRuntimeEvent => { - if (event.provider !== source.provider) { - throw new Error( - `ProviderService.streamEvents: provider instance '${source.instanceId}' is backed by driver '${source.provider}' but emitted driver '${event.provider}'.`, - ); - } - if (event.providerInstanceId !== undefined && event.providerInstanceId !== source.instanceId) { - throw new Error( - `ProviderService.streamEvents: provider instance '${source.instanceId}' emitted event for instance '${event.providerInstanceId}'.`, - ); - } - return { ...event, providerInstanceId: source.instanceId }; -}; - -const makeProviderService = Effect.fn("makeProviderService")(function* ( - options?: ProviderServiceLiveOptions, -) { - const analytics = yield* Effect.service(AnalyticsService.AnalyticsService); - const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - // Options-provided logger wins (test overrides); otherwise we take whatever - // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical - // log writer is attached", which downstream code already handles as a - // no-op. - const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical; - - const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; - const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; - const runtimeEventPubSub = yield* PubSub.unbounded(); - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => - McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( - Effect.tap((credential) => - credential - ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)) - : Effect.void, - ), - ); - const clearMcpSession = (threadId: ThreadId) => - McpSessionRegistry.revokeActiveMcpThread(threadId).pipe( - Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))), - ); - - const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - Effect.succeed(event).pipe( - Effect.tap((canonicalEvent) => - canonicalEventLogger - ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId) - : Effect.void, - ), - Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), - Effect.asVoid, - ); - - const requireBindingInstanceId = ( - operation: string, - payload: { - readonly providerInstanceId?: ProviderInstanceId | undefined; - readonly provider?: ProviderDriverKind | undefined; - }, - ): Effect.Effect => - payload.providerInstanceId !== undefined - ? Effect.succeed(payload.providerInstanceId) - : Effect.fail( - toValidationError( - operation, - payload.provider - ? `Provider instance id is required for provider '${payload.provider}'.` - : "Provider instance id is required.", - ), - ); - - const upsertSessionBinding = ( - session: ProviderSession, - threadId: ThreadId, - extra?: { - readonly modelSelection?: unknown; - readonly lastRuntimeEvent?: string; - readonly lastRuntimeEventAt?: string; - }, - ) => - Effect.gen(function* () { - const providerInstanceId = yield* requireBindingInstanceId( - "ProviderService.upsertSessionBinding", - session, - ); - yield* directory.upsert({ - threadId, - provider: session.provider, - providerInstanceId, - runtimeMode: session.runtimeMode, - status: toRuntimeStatus(session), - ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), - runtimePayload: toRuntimePayloadFromSession(session, extra), - }); - }); - - const processRuntimeEvent = ( - source: { - readonly instanceId: ProviderInstanceId; - readonly provider: ProviderDriverKind; - }, - event: ProviderRuntimeEvent, - ): Effect.Effect => - Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe( - Effect.flatMap((canonicalEvent) => - increment(providerRuntimeEventsTotal, { - provider: canonicalEvent.provider, - eventType: canonicalEvent.type, - }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), - ), - ); - - // `subscribedAdapters` is our source-of-truth for "which instance adapters - // are currently wired into the runtime event bus". It both tracks the set - // of live subscriptions (so `reconcileInstanceSubscriptions` can diff and - // fork only the *new* or *rebuilt* ones) and serves as the dynamic adapter - // list consumed by `stopStaleSessionsForThread`, `listSessions`, and - // `runStopAll` — replacing the pre-Slice-D startup snapshot so hot-added - // instances become visible to those call sites as soon as settings edits - // land. - const subscribedAdapters = yield* Ref.make( - new Map>(), - ); - - const getAdapterEntries = Ref.get(subscribedAdapters).pipe( - Effect.map((map) => Array.from(map.entries())), - ); - - // Rebuild the map of id → adapter from the registry and fork a new event - // subscription for every instance that is either brand new or whose adapter - // identity changed (indicating the underlying `ProviderInstance` was torn - // down and rebuilt by `ProviderInstanceRegistry.reconcile`). Orphaned - // fibers for removed/replaced instances exit on their own because their - // adapter's `streamEvents` source terminates when the old scope closes. - const reconcileInstanceSubscriptions = Effect.gen(function* () { - const previous = yield* Ref.get(subscribedAdapters); - const currentIds = yield* registry.listInstances(); - const next = new Map>(); - for (const id of currentIds) { - const adapterOption = yield* registry - .getByInstance(id) - .pipe(Effect.tapError(Effect.logWarning), Effect.option); - if (Option.isNone(adapterOption)) continue; - const adapter = adapterOption.value; - next.set(id, adapter); - if (previous.get(id) !== adapter) { - yield* Stream.runForEach(adapter.streamEvents, (event) => - processRuntimeEvent( - { - instanceId: id, - provider: adapter.provider, - }, - event, - ), - ).pipe(Effect.forkScoped); - } - } - yield* Ref.set(subscribedAdapters, next); - }); - - const instanceChanges = yield* registry.subscribeChanges; - yield* reconcileInstanceSubscriptions; - yield* Stream.runForEach( - Stream.fromSubscription(instanceChanges), - () => reconcileInstanceSubscriptions, - ).pipe(Effect.forkScoped); - - const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: { - readonly binding: ProviderSessionDirectory.ProviderRuntimeBinding; - readonly operation: string; - }) { - const bindingInstanceId = yield* requireBindingInstanceId(input.operation, input.binding); - yield* Effect.annotateCurrentSpan({ - "provider.operation": "recover-session", - "provider.kind": input.binding.provider, - "provider.instance_id": bindingInstanceId, - "provider.thread_id": input.binding.threadId, - }); - return yield* Effect.gen(function* () { - const adapter = yield* registry.getByInstance(bindingInstanceId); - const hasResumeCursor = - input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; - const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); - if (hasActiveSession) { - const activeSessions = yield* adapter.listSessions(); - const existing = activeSessions.find( - (session) => session.threadId === input.binding.threadId, - ); - if (existing) { - yield* upsertSessionBinding( - { ...existing, providerInstanceId: bindingInstanceId }, - input.binding.threadId, - ); - yield* analytics.record("provider.session.recovered", { - provider: existing.provider, - strategy: "adopt-existing", - hasResumeCursor: existing.resumeCursor !== undefined, - }); - return { adapter, session: existing } as const; - } - } - - if (!hasResumeCursor) { - return yield* toValidationError( - input.operation, - `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`, - ); - } - - const persistedCwd = readPersistedCwd(input.binding.runtimePayload); - const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - - yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); - const resumed = yield* adapter - .startSession({ - threadId: input.binding.threadId, - provider: input.binding.provider, - providerInstanceId: bindingInstanceId, - ...(persistedCwd ? { cwd: persistedCwd } : {}), - ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), - ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), - runtimeMode: input.binding.runtimeMode ?? "full-access", - }) - .pipe(Effect.onError(() => clearMcpSession(input.binding.threadId))); - if (resumed.provider !== adapter.provider) { - yield* clearMcpSession(input.binding.threadId); - return yield* toValidationError( - input.operation, - `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, - ); - } - - yield* upsertSessionBinding( - { ...resumed, providerInstanceId: bindingInstanceId }, - input.binding.threadId, - ); - yield* analytics.record("provider.session.recovered", { - provider: resumed.provider, - strategy: "resume-thread", - hasResumeCursor: resumed.resumeCursor !== undefined, - }); - return { adapter, session: resumed } as const; - }).pipe( - withMetrics({ - counter: providerSessionsTotal, - attributes: providerMetricAttributes(input.binding.provider, { - operation: "recover", - }), - }), - ); - }); - - const resolveRoutableSession = Effect.fn("resolveRoutableSession")(function* (input: { - readonly threadId: ThreadId; - readonly operation: string; - readonly allowRecovery: boolean; - }) { - const bindingOption = yield* directory.getBinding(input.threadId); - const binding = Option.getOrUndefined(bindingOption); - if (!binding) { - return yield* toValidationError( - input.operation, - `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, - ); - } - const instanceId = yield* requireBindingInstanceId(input.operation, binding); - const adapter = yield* registry.getByInstance(instanceId); - - const hasRequestedSession = yield* adapter.hasSession(input.threadId); - if (hasRequestedSession) { - return { - adapter, - instanceId, - threadId: input.threadId, - isActive: true, - } as const; - } - - if (!input.allowRecovery) { - return { - adapter, - instanceId, - threadId: input.threadId, - isActive: false, - } as const; - } - - const recovered = yield* recoverSessionForThread({ - binding, - operation: input.operation, - }); - return { - adapter: recovered.adapter, - instanceId, - threadId: input.threadId, - isActive: true, - } as const; - }); - - const stopStaleSessionsForThread = Effect.fn("stopStaleSessionsForThread")(function* (input: { - readonly threadId: ThreadId; - readonly currentInstanceId: ProviderInstanceId; - }) { - const currentAdapters = yield* getAdapterEntries; - yield* Effect.forEach( - currentAdapters, - ([instanceId, adapter]) => - instanceId === input.currentInstanceId - ? Effect.void - : Effect.gen(function* () { - const hasSession = yield* adapter.hasSession(input.threadId); - if (!hasSession) { - return; - } - - yield* adapter.stopSession(input.threadId).pipe( - Effect.tap(() => - analytics.record("provider.session.stopped", { - provider: adapter.provider, - }), - ), - Effect.catchCause((cause) => - Effect.logWarning("provider.session.stop-stale-failed", { - threadId: input.threadId, - provider: adapter.provider, - cause, - }), - ), - ); - }), - { discard: true }, - ); - }); - - const startSession: ProviderServiceMethod<"startSession"> = Effect.fn("startSession")( - function* (threadId, rawInput) { - const parsed = yield* decodeInputOrValidationError({ - operation: "ProviderService.startSession", - schema: ProviderSessionStartInput, - payload: rawInput, - }); - - const resolvedInstanceId = yield* requireBindingInstanceId( - "ProviderService.startSession", - parsed, - ); - let metricProvider = parsed.provider ?? String(resolvedInstanceId); - yield* Effect.annotateCurrentSpan({ - "provider.operation": "start-session", - "provider.instance_id": resolvedInstanceId, - "provider.thread_id": threadId, - "provider.runtime_mode": parsed.runtimeMode, - }); - return yield* Effect.gen(function* () { - const instanceInfo = yield* registry.getInstanceInfo(resolvedInstanceId); - const resolvedProvider = instanceInfo.driverKind; - metricProvider = resolvedProvider; - if (parsed.provider !== undefined && parsed.provider !== resolvedProvider) { - return yield* toValidationError( - "ProviderService.startSession", - `Provider instance '${resolvedInstanceId}' belongs to driver '${resolvedProvider}', not '${parsed.provider}'.`, - ); - } - const input = { - ...parsed, - threadId, - provider: resolvedProvider, - }; - if (!instanceInfo.enabled) { - return yield* toValidationError( - "ProviderService.startSession", - `Provider instance '${resolvedInstanceId}' is disabled in T3 Code settings.`, - ); - } - const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); - const effectiveResumeCursor = - input.resumeCursor ?? - (persistedBinding?.providerInstanceId === resolvedInstanceId - ? persistedBinding.resumeCursor - : undefined); - const effectiveCwd = - input.cwd ?? - (persistedBinding?.providerInstanceId === resolvedInstanceId - ? readPersistedCwd(persistedBinding.runtimePayload) - : undefined); - yield* Effect.annotateCurrentSpan({ - "provider.kind": resolvedProvider, - "provider.resume_cursor.source": - input.resumeCursor !== undefined - ? "request" - : effectiveResumeCursor !== undefined && - persistedBinding?.providerInstanceId === resolvedInstanceId - ? "persisted" - : "none", - "provider.resume_cursor.present": effectiveResumeCursor !== undefined, - "provider.cwd.source": - input.cwd !== undefined - ? "request" - : effectiveCwd !== undefined && - persistedBinding?.providerInstanceId === resolvedInstanceId - ? "persisted" - : "none", - "provider.cwd.effective": effectiveCwd ?? "", - }); - const adapter = yield* registry.getByInstance(resolvedInstanceId); - yield* prepareMcpSession(threadId, resolvedInstanceId); - const session = yield* adapter - .startSession({ - ...input, - providerInstanceId: resolvedInstanceId, - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), - }) - .pipe(Effect.onError(() => clearMcpSession(threadId))); - - if (session.provider !== adapter.provider) { - yield* clearMcpSession(threadId); - return yield* toValidationError( - "ProviderService.startSession", - `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, - ); - } - const sessionWithInstance = { - ...session, - providerInstanceId: resolvedInstanceId, - }; - - yield* stopStaleSessionsForThread({ - threadId, - currentInstanceId: resolvedInstanceId, - }); - yield* upsertSessionBinding(sessionWithInstance, threadId, { - modelSelection: input.modelSelection, - }); - yield* analytics.record("provider.session.started", { - provider: sessionWithInstance.provider, - runtimeMode: input.runtimeMode, - hasResumeCursor: sessionWithInstance.resumeCursor !== undefined, - hasCwd: typeof effectiveCwd === "string" && effectiveCwd.trim().length > 0, - hasModel: - typeof input.modelSelection?.model === "string" && - input.modelSelection.model.trim().length > 0, - }); - - return sessionWithInstance; - }).pipe( - withMetrics({ - counter: providerSessionsTotal, - attributes: () => - providerMetricAttributes(metricProvider, { - operation: "start", - }), - }), - ); - }, - ); - - const sendTurn: ProviderServiceMethod<"sendTurn"> = Effect.fn("sendTurn")(function* (rawInput) { - const parsed = yield* decodeInputOrValidationError({ - operation: "ProviderService.sendTurn", - schema: ProviderSendTurnInput, - payload: rawInput, - }); - - const input = { - ...parsed, - attachments: parsed.attachments ?? [], - }; - if (!input.input && input.attachments.length === 0) { - return yield* toValidationError( - "ProviderService.sendTurn", - "Either input text or at least one attachment is required", - ); - } - yield* Effect.annotateCurrentSpan({ - "provider.operation": "send-turn", - "provider.thread_id": input.threadId, - "provider.interaction_mode": input.interactionMode, - "provider.attachment_count": input.attachments.length, - }); - let metricProvider = "unknown"; - let metricModel = input.modelSelection?.model; - return yield* Effect.gen(function* () { - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.sendTurn", - allowRecovery: true, - }); - metricProvider = routed.adapter.provider; - metricModel = input.modelSelection?.model; - yield* Effect.annotateCurrentSpan({ - "provider.kind": routed.adapter.provider, - ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), - }); - const turn = yield* routed.adapter.sendTurn(input); - yield* directory.upsert({ - threadId: input.threadId, - provider: routed.adapter.provider, - providerInstanceId: routed.instanceId, - status: "running", - ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), - runtimePayload: { - ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), - activeTurnId: turn.turnId, - lastRuntimeEvent: "provider.sendTurn", - lastRuntimeEventAt: yield* nowIso, - }, - }); - yield* analytics.record("provider.turn.sent", { - provider: routed.adapter.provider, - model: input.modelSelection?.model, - interactionMode: input.interactionMode, - attachmentCount: input.attachments.length, - hasInput: typeof input.input === "string" && input.input.trim().length > 0, - }); - return turn; - }).pipe( - withMetrics({ - counter: providerTurnsTotal, - timer: providerTurnDuration, - attributes: () => - providerTurnMetricAttributes({ - provider: metricProvider, - model: metricModel, - extra: { - operation: "send", - }, - }), - }), - ); - }); - - const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")( - function* (rawInput) { - const input = yield* decodeInputOrValidationError({ - operation: "ProviderService.interruptTurn", - schema: ProviderInterruptTurnInput, - payload: rawInput, - }); - let metricProvider = "unknown"; - return yield* Effect.gen(function* () { - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.interruptTurn", - allowRecovery: true, - }); - metricProvider = routed.adapter.provider; - yield* Effect.annotateCurrentSpan({ - "provider.operation": "interrupt-turn", - "provider.kind": routed.adapter.provider, - "provider.thread_id": input.threadId, - "provider.turn_id": input.turnId, - }); - yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); - yield* analytics.record("provider.turn.interrupted", { - provider: routed.adapter.provider, - }); - }).pipe( - withMetrics({ - counter: providerTurnsTotal, - outcomeAttributes: () => - providerMetricAttributes(metricProvider, { - operation: "interrupt", - }), - }), - ); - }, - ); - - const respondToRequest: ProviderServiceMethod<"respondToRequest"> = Effect.fn("respondToRequest")( - function* (rawInput) { - const input = yield* decodeInputOrValidationError({ - operation: "ProviderService.respondToRequest", - schema: ProviderRespondToRequestInput, - payload: rawInput, - }); - let metricProvider = "unknown"; - return yield* Effect.gen(function* () { - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.respondToRequest", - allowRecovery: true, - }); - metricProvider = routed.adapter.provider; - yield* Effect.annotateCurrentSpan({ - "provider.operation": "respond-to-request", - "provider.kind": routed.adapter.provider, - "provider.thread_id": input.threadId, - "provider.request_id": input.requestId, - }); - yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); - yield* analytics.record("provider.request.responded", { - provider: routed.adapter.provider, - decision: input.decision, - }); - }).pipe( - withMetrics({ - counter: providerTurnsTotal, - outcomeAttributes: () => - providerMetricAttributes(metricProvider, { - operation: "approval-response", - }), - }), - ); - }, - ); - - const respondToUserInput: ProviderServiceMethod<"respondToUserInput"> = Effect.fn( - "respondToUserInput", - )(function* (rawInput) { - const input = yield* decodeInputOrValidationError({ - operation: "ProviderService.respondToUserInput", - schema: ProviderRespondToUserInputInput, - payload: rawInput, - }); - let metricProvider = "unknown"; - return yield* Effect.gen(function* () { - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.respondToUserInput", - allowRecovery: true, - }); - metricProvider = routed.adapter.provider; - yield* Effect.annotateCurrentSpan({ - "provider.operation": "respond-to-user-input", - "provider.kind": routed.adapter.provider, - "provider.thread_id": input.threadId, - "provider.request_id": input.requestId, - }); - yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers); - }).pipe( - withMetrics({ - counter: providerTurnsTotal, - outcomeAttributes: () => - providerMetricAttributes(metricProvider, { - operation: "user-input-response", - }), - }), - ); - }); - - const stopSession: ProviderServiceMethod<"stopSession"> = Effect.fn("stopSession")( - function* (rawInput) { - const input = yield* decodeInputOrValidationError({ - operation: "ProviderService.stopSession", - schema: ProviderStopSessionInput, - payload: rawInput, - }); - let metricProvider = "unknown"; - return yield* Effect.gen(function* () { - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.stopSession", - allowRecovery: false, - }); - metricProvider = routed.adapter.provider; - yield* Effect.annotateCurrentSpan({ - "provider.operation": "stop-session", - "provider.kind": routed.adapter.provider, - "provider.thread_id": input.threadId, - }); - if (routed.isActive) { - yield* routed.adapter.stopSession(routed.threadId); - } - yield* clearMcpSession(input.threadId); - yield* directory.upsert({ - threadId: input.threadId, - provider: routed.adapter.provider, - providerInstanceId: routed.instanceId, - status: "stopped", - runtimePayload: { - activeTurnId: null, - }, - }); - yield* analytics.record("provider.session.stopped", { - provider: routed.adapter.provider, - }); - }).pipe( - withMetrics({ - counter: providerSessionsTotal, - outcomeAttributes: () => - providerMetricAttributes(metricProvider, { - operation: "stop", - }), - }), - ); - }, - ); - - const listSessions: ProviderServiceMethod<"listSessions"> = Effect.fn("listSessions")( - function* () { - const currentAdapters = yield* getAdapterEntries; - const sessionsByProvider = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => - adapter.listSessions().pipe( - Effect.map((sessions) => - sessions.map((session) => ({ - ...session, - providerInstanceId: instanceId, - })), - ), - ), - ); - const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); - const persistedBindings = yield* directory.listThreadIds().pipe( - Effect.flatMap((threadIds) => - Effect.forEach( - threadIds, - (threadId) => - directory - .getBinding(threadId) - .pipe( - Effect.orElseSucceed(() => - Option.none(), - ), - ), - { concurrency: "unbounded" }, - ), - ), - Effect.orElseSucceed( - () => [] as Array>, - ), - ); - const bindingsByThreadId = new Map< - ThreadId, - ProviderSessionDirectory.ProviderRuntimeBinding - >(); - for (const bindingOption of persistedBindings) { - const binding = Option.getOrUndefined(bindingOption); - if (binding) { - bindingsByThreadId.set(binding.threadId, binding); - } - } - - const sessions: ProviderSession[] = []; - for (const session of activeSessions) { - const binding = bindingsByThreadId.get(session.threadId); - if (!binding) { - sessions.push(session); - continue; - } - - const overrides: { - resumeCursor?: ProviderSession["resumeCursor"]; - runtimeMode?: ProviderSession["runtimeMode"]; - providerInstanceId?: ProviderSession["providerInstanceId"]; - } = {}; - overrides.providerInstanceId = dieOnMissingBindingInstanceId( - "ProviderService.listSessions", - binding, - ); - if (binding.provider !== session.provider) { - return yield* Effect.die( - new Error( - `ProviderService.listSessions: thread '${session.threadId}' is active on provider '${session.provider}' but persisted binding names provider '${binding.provider}'.`, - ), - ); - } - if (overrides.providerInstanceId !== session.providerInstanceId) { - return yield* Effect.die( - new Error( - `ProviderService.listSessions: thread '${session.threadId}' is active on provider instance '${session.providerInstanceId}' but persisted binding names '${overrides.providerInstanceId}'.`, - ), - ); - } - if (session.resumeCursor === undefined && binding.resumeCursor !== undefined) { - overrides.resumeCursor = binding.resumeCursor; - } - if (binding.runtimeMode !== undefined) { - overrides.runtimeMode = binding.runtimeMode; - } - sessions.push(Object.assign({}, session, overrides)); - } - return sessions; - }, - ); - - const getCapabilities: ProviderServiceMethod<"getCapabilities"> = (instanceId) => - registry.getByInstance(instanceId).pipe(Effect.map((adapter) => adapter.capabilities)); - - const getInstanceInfo: ProviderServiceMethod<"getInstanceInfo"> = (instanceId) => - registry.getInstanceInfo(instanceId); - - const rollbackConversation: ProviderServiceMethod<"rollbackConversation"> = Effect.fn( - "rollbackConversation", - )(function* (rawInput) { - const input = yield* decodeInputOrValidationError({ - operation: "ProviderService.rollbackConversation", - schema: ProviderRollbackConversationInput, - payload: rawInput, - }); - if (input.numTurns === 0) { - return; - } - let metricProvider = "unknown"; - return yield* Effect.gen(function* () { - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.rollbackConversation", - allowRecovery: true, - }); - metricProvider = routed.adapter.provider; - yield* Effect.annotateCurrentSpan({ - "provider.operation": "rollback-conversation", - "provider.kind": routed.adapter.provider, - "provider.thread_id": input.threadId, - "provider.rollback_turns": input.numTurns, - }); - yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns); - yield* analytics.record("provider.conversation.rolled_back", { - provider: routed.adapter.provider, - turns: input.numTurns, - }); - }).pipe( - withMetrics({ - counter: providerTurnsTotal, - outcomeAttributes: () => - providerMetricAttributes(metricProvider, { - operation: "rollback", - }), - }), - ); - }); - - const runStopAll = Effect.fn("runStopAll")(function* () { - const threadIds = yield* directory.listThreadIds(); - const currentAdapters = yield* getAdapterEntries; - const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => - adapter.listSessions().pipe( - Effect.map((sessions) => - sessions.map((session) => ({ - ...session, - providerInstanceId: instanceId, - })), - ), - ), - ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); - yield* Effect.forEach(activeSessions, (session) => - Effect.flatMap(nowIso, (lastRuntimeEventAt) => - upsertSessionBinding(session, session.threadId, { - lastRuntimeEvent: "provider.stopAll", - lastRuntimeEventAt, - }), - ), - ).pipe(Effect.asVoid); - yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); - yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); - McpProviderSession.clearAllMcpProviderSessions(); - const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); - yield* Effect.forEach(bindings, (binding) => - Effect.gen(function* () { - const providerInstanceId = dieOnMissingBindingInstanceId( - "ProviderService.stopAll", - binding, - ); - return yield* directory.upsert({ - threadId: binding.threadId, - provider: binding.provider, - providerInstanceId, - status: "stopped", - runtimePayload: { - activeTurnId: null, - lastRuntimeEvent: "provider.stopAll", - lastRuntimeEventAt: yield* nowIso, - }, - }); - }), - ).pipe(Effect.asVoid); - yield* analytics.record("provider.sessions.stopped_all", { - sessionCount: threadIds.length, - }); - yield* analytics.flush; - }); - - yield* Effect.addFinalizer(() => - runStopAll().pipe( - Effect.catchCause((cause) => - Effect.logWarning("failed to stop provider service", { - errorTag: causeErrorTag(cause), - }), - ), - ), - ); - - return { - startSession, - sendTurn, - interruptTurn, - respondToRequest, - respondToUserInput, - stopSession, - listSessions, - getCapabilities, - getInstanceInfo, - rollbackConversation, - // Each access creates a fresh PubSub subscription so that multiple - // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each - // independently receive all runtime events. - get streamEvents(): ProviderServiceMethod<"streamEvents"> { - return Stream.fromPubSub(runtimeEventPubSub); - }, - } satisfies ProviderService.ProviderService["Service"]; -}); - -export const ProviderServiceLive = Layer.effect( - ProviderService.ProviderService, - makeProviderService(), -); - -export function makeProviderServiceLive(options?: ProviderServiceLiveOptions) { - return Layer.effect(ProviderService.ProviderService, makeProviderService(options)); -} +export const makeProviderServiceLive = (options?: ProviderService.ProviderServiceOptions) => + Layer.effect(ProviderService.ProviderService, ProviderService.make(options)); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 23075bd9a06..ffc06ee2b5f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,201 +1,6 @@ -import { defaultInstanceIdForDriver, ProviderDriverKind, type ThreadId } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; +// Compatibility shim for the intentionally excluded orchestration harness. +import * as ProviderSessionDirectory from "../ProviderSessionDirectory.ts"; -import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; -import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; -import { - ProviderSessionDirectory, - type ProviderRuntimeBinding, - type ProviderRuntimeBindingWithMetadata, - type ProviderSessionDirectoryShape, -} from "../Services/ProviderSessionDirectory.ts"; -const decodeProviderDriverKindValue = Schema.decodeUnknownEffect(ProviderDriverKind); +export const ProviderSessionDirectoryLive = ProviderSessionDirectory.layer; -function toPersistenceError(operation: string) { - return (cause: unknown) => - new ProviderSessionDirectoryPersistenceError({ - operation, - detail: `Failed to execute ${operation}.`, - cause, - }); -} - -function decodeProviderDriverKind( - providerName: string, - operation: string, -): Effect.Effect { - return decodeProviderDriverKindValue(providerName).pipe( - Effect.mapError( - (cause) => - new ProviderSessionDirectoryPersistenceError({ - operation, - detail: `Unknown persisted provider '${providerName}'.`, - cause, - }), - ), - ); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function mergeRuntimePayload( - existing: unknown | null, - next: unknown | null | undefined, -): unknown | null { - if (next === undefined) { - return existing ?? null; - } - if (isRecord(existing) && isRecord(next)) { - return { ...existing, ...next }; - } - return next; -} - -function toRuntimeBinding( - runtime: ProviderSessionRuntime.ProviderSessionRuntime, - operation: string, -): Effect.Effect { - return decodeProviderDriverKind(runtime.providerName, operation).pipe( - Effect.map( - (provider) => - ({ - threadId: runtime.threadId, - provider, - // Migration boundary only: rows written before the instance split - // have a null provider_instance_id. Promote them as they leave - // persistence so hot routing code never has to infer an instance - // from a driver kind. - providerInstanceId: runtime.providerInstanceId ?? defaultInstanceIdForDriver(provider), - adapterKey: runtime.adapterKey, - runtimeMode: runtime.runtimeMode, - status: runtime.status, - resumeCursor: runtime.resumeCursor, - runtimePayload: runtime.runtimePayload, - lastSeenAt: runtime.lastSeenAt, - }) satisfies ProviderRuntimeBindingWithMetadata, - ), - ); -} - -const makeProviderSessionDirectory = Effect.gen(function* () { - const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; - - const getBinding = (threadId: ThreadId) => - repository.getByThreadId({ threadId }).pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getByThreadId")), - Effect.flatMap((runtime) => - Option.match(runtime, { - onNone: () => Effect.succeed(Option.none()), - onSome: (value) => - toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe( - Effect.map((binding) => Option.some(binding)), - ), - }), - ), - ); - - const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { - const existing = yield* repository - .getByThreadId({ threadId: binding.threadId }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); - - const existingRuntime = Option.getOrUndefined(existing); - const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; - if (!resolvedThreadId) { - return yield* new ProviderValidationError({ - operation: "ProviderSessionDirectory.upsert", - issue: "threadId must be a non-empty string.", - }); - } - - const now = DateTime.formatIso(yield* DateTime.now); - const providerChanged = - existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; - const providerInstanceId = - binding.providerInstanceId ?? (!providerChanged ? existingRuntime?.providerInstanceId : null); - if (providerInstanceId === null || providerInstanceId === undefined) { - return yield* new ProviderValidationError({ - operation: "ProviderSessionDirectory.upsert", - issue: "providerInstanceId is required for provider session runtime bindings.", - }); - } - yield* repository - .upsert({ - threadId: resolvedThreadId, - providerName: binding.provider, - providerInstanceId, - adapterKey: - binding.adapterKey ?? - (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); - }); - - const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) => - getBinding(threadId).pipe( - Effect.flatMap((binding) => - Option.match(binding, { - onSome: (value) => Effect.succeed(value.provider), - onNone: () => - Effect.fail( - new ProviderSessionDirectoryPersistenceError({ - operation: "ProviderSessionDirectory.getProvider", - detail: `No persisted provider binding found for thread '${threadId}'.`, - }), - ), - }), - ), - ); - - const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => - repository.list().pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), - Effect.map((rows) => rows.map((row) => row.threadId)), - ); - - const listBindings: ProviderSessionDirectoryShape["listBindings"] = () => - repository.list().pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), - Effect.flatMap((rows) => - Effect.forEach( - rows, - (row) => toRuntimeBinding(row, "ProviderSessionDirectory.listBindings"), - { concurrency: "unbounded" }, - ), - ), - ); - - return { - upsert, - getProvider, - getBinding, - listThreadIds, - listBindings, - } satisfies ProviderSessionDirectoryShape; -}); - -export const ProviderSessionDirectoryLive = Layer.effect( - ProviderSessionDirectory, - makeProviderSessionDirectory, -); - -export function makeProviderSessionDirectoryLive() { - return Layer.effect(ProviderSessionDirectory, makeProviderSessionDirectory); -} +export const makeProviderSessionDirectoryLive = () => ProviderSessionDirectory.layer; diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/ProviderAdapterRegistry.test.ts similarity index 82% rename from apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts rename to apps/server/src/provider/ProviderAdapterRegistry.test.ts index c4145ecf1a0..bf80c33877c 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/ProviderAdapterRegistry.test.ts @@ -10,16 +10,15 @@ import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; -import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts"; -import type * as CodexAdapter from "../Services/CodexAdapter.ts"; -import type * as CursorAdapter from "../Services/CursorAdapter.ts"; -import type * as OpenCodeAdapter from "../Services/OpenCodeAdapter.ts"; -import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; -import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; -import type { ProviderInstance } from "../ProviderDriver.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; -import type * as TextGeneration from "../../textGeneration/TextGeneration.ts"; -import * as ProviderAdapterRegistryLayer from "./ProviderAdapterRegistry.ts"; +import type { ClaudeAdapterShape } from "./Services/ClaudeAdapter.ts"; +import type { CodexAdapterShape } from "./Services/CodexAdapter.ts"; +import type { CursorAdapterShape } from "./Services/CursorAdapter.ts"; +import type { OpenCodeAdapterShape } from "./Services/OpenCodeAdapter.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "./ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import type * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; const CODEX_DRIVER = ProviderDriverKind.make("codex"); @@ -27,7 +26,7 @@ const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); -const fakeCodexAdapter: CodexAdapter.CodexAdapterShape = { +const fakeCodexAdapter: CodexAdapterShape = { provider: CODEX_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -44,7 +43,7 @@ const fakeCodexAdapter: CodexAdapter.CodexAdapterShape = { streamEvents: Stream.empty, }; -const fakeClaudeAdapter: ClaudeAdapter.ClaudeAdapterShape = { +const fakeClaudeAdapter: ClaudeAdapterShape = { provider: CLAUDE_AGENT_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -61,7 +60,7 @@ const fakeClaudeAdapter: ClaudeAdapter.ClaudeAdapterShape = { streamEvents: Stream.empty, }; -const fakeOpenCodeAdapter: OpenCodeAdapter.OpenCodeAdapterShape = { +const fakeOpenCodeAdapter: OpenCodeAdapterShape = { provider: OPENCODE_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -78,7 +77,7 @@ const fakeOpenCodeAdapter: OpenCodeAdapter.OpenCodeAdapterShape = { streamEvents: Stream.empty, }; -const fakeCursorAdapter: CursorAdapter.CursorAdapterShape = { +const fakeCursorAdapter: CursorAdapterShape = { provider: CURSOR_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -95,7 +94,7 @@ const fakeCursorAdapter: CursorAdapter.CursorAdapterShape = { streamEvents: Stream.empty, }; -// ProviderAdapterRegistryLive is now a facade over ProviderInstanceRegistry — +// ProviderAdapterRegistry is a facade over ProviderInstanceRegistry — // it walks `listInstances` once at boot and surfaces the default-instance // adapter keyed by its driver kind. To test the facade we supply four fake // instances whose `instanceId === defaultInstanceIdForDriver(driverKind)` so @@ -147,14 +146,11 @@ const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry.Provide }); const layer = Layer.mergeAll( - Layer.provide( - ProviderAdapterRegistryLayer.ProviderAdapterRegistryLive, - fakeInstanceRegistryLayer, - ), + Layer.provide(ProviderAdapterRegistry.layer, fakeInstanceRegistryLayer), NodeServices.layer, ); -it.layer(layer)("ProviderAdapterRegistryLive", (it) => { +it.layer(layer)("ProviderAdapterRegistry", (it) => { it("resolves adapters and routing metadata from provider instances", () => Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; diff --git a/apps/server/src/provider/ProviderAdapterRegistry.ts b/apps/server/src/provider/ProviderAdapterRegistry.ts new file mode 100644 index 00000000000..d627e25c0fe --- /dev/null +++ b/apps/server/src/provider/ProviderAdapterRegistry.ts @@ -0,0 +1,150 @@ +/** + * ProviderAdapterRegistry - Lookup boundary for provider adapter implementations. + * + * Maps a `ProviderInstanceId` (the new per-instance routing key) or a + * `ProviderDriverKind` (legacy single-instance-per-driver key) to the concrete + * adapter service (Codex, Claude, etc). It does not own session lifecycle + * or routing rules; `ProviderService` uses this registry together with + * `ProviderSessionDirectory`. + * + * During the driver/instance migration this tag exposes both flavours: + * + * - `getByInstance` / `listInstances` — new per-instance routing. Callers + * that already know an `instanceId` (threads, sessions, events) + * should prefer these. + * (`defaultInstanceIdForDriver(kind) === kind`), matching the pre-Slice-D + * behaviour. New code should not grow additional callers of the kind-keyed + * methods; they exist so the settings UI, WS refresh RPC, and a handful + * of legacy persisted rows can still be routed during the rollout. + * + * @module ProviderAdapterRegistry + */ +import { + defaultInstanceIdForDriver, + ProviderInstanceId, + type ProviderDriverKind, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as PubSub from "effect/PubSub"; +import type * as Scope from "effect/Scope"; +import type * as Stream from "effect/Stream"; + +import { ProviderUnsupportedError, type ProviderAdapterError } from "./Errors.ts"; +import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; +import type { ProviderContinuationIdentity } from "./ProviderDriver.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; + +export interface ProviderInstanceRoutingInfo { + readonly instanceId: ProviderInstanceId; + readonly driverKind: ProviderDriverKind; + readonly displayName: string | undefined; + readonly accentColor?: string | undefined; + readonly enabled: boolean; + readonly continuationIdentity: ProviderContinuationIdentity; +} + +export class ProviderAdapterRegistry extends Context.Service< + ProviderAdapterRegistry, + { + /** + * Resolve the adapter for a specific instance id. Returns + * `ProviderUnsupportedError` if no such live instance is registered. + */ + readonly getByInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect, ProviderUnsupportedError>; + + /** Resolve routing metadata for a specific live provider instance. */ + readonly getInstanceInfo: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + + /** + * List every live instance id. Unavailable shadow instances are excluded + * because callers use these ids with `getByInstance`. + */ + readonly listInstances: () => Effect.Effect>; + + /** + * List provider kinds whose default instance is currently registered. + * + * @deprecated Prefer `listInstances`; this remains for migration-era + * callers that still address providers by driver kind. + */ + readonly listProviders: () => Effect.Effect>; + + /** + * Emits whenever the live instance set changes. Consumers should re-read + * `listInstances` and reconcile their per-instance subscriptions. + */ + readonly streamChanges: Stream.Stream; + + /** + * Acquire the change subscription synchronously in the caller's scope, + * avoiding the publish race inherent in forking `Stream.fromPubSub`. + */ + readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; + } +>()("t3/provider/ProviderAdapterRegistry") {} + +export const make = Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + + const getByInstance: ProviderAdapterRegistry["Service"]["getByInstance"] = (instanceId) => + registry + .getInstance(instanceId) + .pipe( + Effect.flatMap((instance) => + instance === undefined + ? Effect.fail(new ProviderUnsupportedError({ provider: instanceId })) + : Effect.succeed(instance.adapter), + ), + ); + + const getInstanceInfo: ProviderAdapterRegistry["Service"]["getInstanceInfo"] = (instanceId) => + registry.getInstance(instanceId).pipe( + Effect.flatMap((instance) => + instance === undefined + ? Effect.fail(new ProviderUnsupportedError({ provider: instanceId })) + : Effect.succeed({ + instanceId: instance.instanceId, + driverKind: instance.driverKind, + displayName: instance.displayName, + accentColor: instance.accentColor, + enabled: instance.enabled, + continuationIdentity: instance.continuationIdentity, + }), + ), + ); + + const listInstances: ProviderAdapterRegistry["Service"]["listInstances"] = () => + registry.listInstances.pipe( + Effect.map((instances) => instances.map((instance) => instance.instanceId)), + ); + + const listProviders: ProviderAdapterRegistry["Service"]["listProviders"] = () => + registry.listInstances.pipe( + Effect.map((instances) => { + const kinds = new Set(); + for (const instance of instances) { + if (instance.instanceId === defaultInstanceIdForDriver(instance.driverKind)) { + kinds.add(instance.driverKind); + } + } + return Array.from(kinds); + }), + ); + + return ProviderAdapterRegistry.of({ + getByInstance, + getInstanceInfo, + listInstances, + listProviders, + streamChanges: registry.streamChanges, + subscribeChanges: registry.subscribeChanges, + }); +}); + +export const layer = Layer.effect(ProviderAdapterRegistry, make); diff --git a/apps/server/src/provider/ProviderEventLoggers.ts b/apps/server/src/provider/ProviderEventLoggers.ts new file mode 100644 index 00000000000..e3328cc03da --- /dev/null +++ b/apps/server/src/provider/ProviderEventLoggers.ts @@ -0,0 +1,79 @@ +/** + * ProviderEventLoggers — single observability service that owns the two + * shared NDJSON streams the provider runtime writes: + * + * - `native` — provider-protocol events as the SDK emits them, written + * from inside each `Adapter` factory. + * - `canonical` — runtime events after `ProviderService` has normalized + * them onto `ProviderRuntimeEvent`. + * + * Why a service tag and not constructor options? + * + * - Adapters are now constructed *inside* drivers (`Driver.create()`), + * not at the boot Layer. There is no longer a single `makeAdapterLive(options)` + * call site where we can hand an `EventNdjsonLogger` in by hand. + * - Multiple driver instances per kind (`codex_personal`, `codex_work`) + * should share one underlying log writer per stream — opening N writers + * against the same rotating file would race the rotation logic. Owning + * the loggers on a single tag keeps that invariant intact. + * - Tests can swap one (or both) loggers with in-memory recorders by + * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of + * juggling per-Layer option threading. + * + * Both fields are optional. `makeEventNdjsonLogger` returns `undefined` when + * the target directory cannot be created; we forward that as `undefined` + * rather than failing the boot Layer, matching the previous best-effort + * behavior of `server.ts`. + * + * @module provider/ProviderEventLoggers + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Config from "../config.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./Layers/EventNdjsonLogger.ts"; + +/** + * Shared logger pair for native + canonical provider event streams. + * + * Service value is intentionally a struct of two optional loggers rather + * than two parallel tags. Construction site is one place + * (`layer`); consumers (drivers, `ProviderService`) read + * one tag and pluck the field they need. + */ +export class ProviderEventLoggers extends Context.Service< + ProviderEventLoggers, + { + readonly native: EventNdjsonLogger | undefined; + readonly canonical: EventNdjsonLogger | undefined; + } +>()("t3/provider/ProviderEventLoggers") {} + +/** + * Constant value used by tests / boot layers that want to opt out of native + * + canonical logging entirely. Keeps the tag non-optional in the type + * system while letting the runtime treat absence as a no-op. + */ +export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { + native: undefined, + canonical: undefined, +}; + +/** + * Live Layer that builds both loggers from `ServerConfig.providerEventLogPath`. + * If the directory create fails for either stream, the corresponding field + * is `undefined` and writes from that stream become no-ops downstream. + */ +export const make = Effect.gen(function* () { + const { providerEventLogPath } = yield* Config.ServerConfig; + const native = yield* makeEventNdjsonLogger(providerEventLogPath, { + stream: "native", + }); + const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, { + stream: "canonical", + }); + return ProviderEventLoggers.of({ native, canonical }); +}); + +export const layer = Layer.effect(ProviderEventLoggers, make); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts similarity index 89% rename from apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts rename to apps/server/src/provider/ProviderInstanceRegistry.test.ts index dbfa7faffea..46597699df7 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -1,5 +1,5 @@ /** - * Multi-instance validation slices for `ProviderInstanceRegistryLive`. + * Multi-instance validation slices for `ProviderInstanceRegistry`. * * Two axes of the driver/registry refactor are exercised here: * @@ -38,16 +38,16 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; -import { CodexDriver } from "../Drivers/CodexDriver.ts"; -import { CursorDriver } from "../Drivers/CursorDriver.ts"; -import { GrokDriver } from "../Drivers/GrokDriver.ts"; -import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; -import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; -import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; -import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; +import * as Config from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { ClaudeDriver } from "./Drivers/ClaudeDriver.ts"; +import { CodexDriver } from "./Drivers/CodexDriver.ts"; +import { CursorDriver } from "./Drivers/CursorDriver.ts"; +import { GrokDriver } from "./Drivers/GrokDriver.ts"; +import { OpenCodeDriver } from "./Drivers/OpenCodeDriver.ts"; +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, @@ -98,19 +98,24 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); -describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { +describe("ProviderInstanceRegistry — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push // `NodeServices.layer` through `Layer.provideMerge` to satisfy that // dependency while still surfacing NodeServices to the test body (the // codex driver's `create` yields `ChildProcessSpawner` directly). - const testLayer = ServerConfig.layerTest(process.cwd(), { + const testLayer = Config.ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-test", }).pipe( Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -142,7 +147,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }, }; - const { registry } = yield* makeProviderInstanceRegistry({ + const { registry } = yield* ProviderInstanceRegistry.make({ drivers: [CodexDriver], configMap, }); @@ -208,7 +213,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }, }; - const { registry } = yield* makeProviderInstanceRegistry({ + const { registry } = yield* ProviderInstanceRegistry.make({ drivers: [CodexDriver], configMap, }); @@ -228,27 +233,32 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { ); }); -describe("ProviderInstanceRegistryLive — all drivers slice", () => { +describe("ProviderInstanceRegistry — all drivers slice", () => { // All drivers need `NodeServices` (ChildProcessSpawner + FileSystem + // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` - // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. - // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via + // at construction time, so we wire `OpenCodeRuntime.layer` into the stack. + // `OpenCodeRuntime.layer` bundles its own `NetService.layer` via // `Layer.provide`, so the only external requirement it still exposes is // `ChildProcessSpawner` — resolved here by piping it through // `provideMerge(NodeServices.layer)`. // // The nested `provideMerge`s read bottom-up: `NodeServices.layer` - // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs + // provides `OpenCodeRuntime.layer`'s deps while keeping its own outputs // surfaced; that merged layer then provides `ServerConfig.layerTest`'s // `FileSystem` dep while keeping everything else surfaced to the test. - const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); - const testLayer = ServerConfig.layerTest(process.cwd(), { + const infraLayer = OpenCodeRuntime.layer.pipe(Layer.provideMerge(NodeServices.layer)); + const testLayer = Config.ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-all-drivers-test", }).pipe( Layer.provideMerge(infraLayer), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); it.live("boots one instance of every shipped driver from a single config map", () => @@ -301,7 +311,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }, }; - const { registry } = yield* makeProviderInstanceRegistry({ + const { registry } = yield* ProviderInstanceRegistry.make({ drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], configMap, }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts similarity index 83% rename from apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts rename to apps/server/src/provider/ProviderInstanceRegistry.ts index b51dc67793e..cfd17b13b0e 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -1,5 +1,5 @@ /** - * ProviderInstanceRegistryLive — runtime implementation of + * ProviderInstanceRegistry — runtime implementation of * `ProviderInstanceRegistry` plus its sibling mutator. * * Materializes every entry in a `ProviderInstanceConfigMap`: @@ -30,7 +30,7 @@ * tears every instance down in reverse order; closing a single instance * (via `reconcile` removing it) leaves the rest untouched. * - * @module provider/Layers/ProviderInstanceRegistryLive + * @module provider/ProviderInstanceRegistry */ import { defaultInstanceIdForDriver, @@ -51,16 +51,69 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { buildUnavailableProviderSnapshot } from "../unavailableProviderSnapshot.ts"; -import { +import { buildUnavailableProviderSnapshot } from "./unavailableProviderSnapshot.ts"; +import type { AnyProviderDriver, ProviderInstance } from "./ProviderDriver.ts"; + +export class ProviderInstanceRegistry extends Context.Service< ProviderInstanceRegistry, - type ProviderInstanceRegistryShape, -} from "../Services/ProviderInstanceRegistry.ts"; -import { + { + /** + * Look up one instance by id. Unknown ids return `undefined`; callers map + * that absence to the appropriate domain error. + */ + readonly getInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + + /** + * Every successfully materialized instance, in stable settings-author + * order. + */ + readonly listInstances: Effect.Effect>; + + /** + * Shadow snapshots for unknown drivers or invalid configurations, ready + * to merge into `ProviderRegistry` output. + */ + readonly listUnavailable: Effect.Effect>; + + /** + * Emits after the registry adds, removes, or rebuilds instances. The + * payload is `void` because consumers re-read both registry lists. + * + * `Stream.fromPubSub` subscribes only when execution begins, so a newly + * forked consumer can miss a publish. Consumers that cannot tolerate that + * gap should acquire `subscribeChanges` first. + */ + readonly streamChanges: Stream.Stream; + + /** + * Acquire a scoped PubSub subscription synchronously before forking the + * consumer loop, ensuring subsequent publishes cannot land in a gap. + */ + readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; + } +>()("t3/provider/ProviderInstanceRegistry") {} + +/** + * Internal mutation boundary used only by registry hydration. + * + * `reconcile` diffs by instance id, closes removed or replaced child scopes + * before creating replacements, materializes additions in fresh child scopes, + * and publishes one change tick after the batch. Reapplying an unchanged map + * is a no-op. + */ +export class ProviderInstanceRegistryMutator extends Context.Service< ProviderInstanceRegistryMutator, - type ProviderInstanceRegistryMutatorShape, -} from "../Services/ProviderInstanceRegistryMutator.ts"; -import type { AnyProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; + { + /** + * Reconcile the live registry with a new configuration map. Individual + * driver creation failures become unavailable shadow snapshots so the + * settings watcher itself never fails. + */ + readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect; + } +>()("t3/provider/ProviderInstanceRegistry/ProviderInstanceRegistryMutator") {} /** * Live registry entry: the materialized `ProviderInstance` + the fresh @@ -315,9 +368,9 @@ const makeReconcile = (input: { * Build the registry's runtime state from a concrete configMap. Returns a * record containing: * - * - `registry`: the read-only `ProviderInstanceRegistryShape` to expose + * - `registry`: the read-only `ProviderInstanceRegistry["Service"]` to expose * under `ProviderInstanceRegistry`. - * - `mutator`: the `ProviderInstanceRegistryMutatorShape` to expose + * - `mutator`: the `ProviderInstanceRegistryMutator["Service"]` to expose * under `ProviderInstanceRegistryMutator`. * - `reconcile`: the raw reconcile function, provided for convenience so * boot-time layers can hydrate an initial map before publishing the @@ -327,13 +380,13 @@ const makeReconcile = (input: { * created during `reconcile`. Closing that scope closes every live * instance. */ -export const makeProviderInstanceRegistry = (input: { +export const make = (input: { readonly drivers: ReadonlyArray>; readonly configMap: ProviderInstanceConfigMap; }): Effect.Effect< { - readonly registry: ProviderInstanceRegistryShape; - readonly mutator: ProviderInstanceRegistryMutatorShape; + readonly registry: ProviderInstanceRegistry["Service"]; + readonly mutator: ProviderInstanceRegistryMutator["Service"]; }, never, R | Scope.Scope @@ -362,14 +415,14 @@ export const makeProviderInstanceRegistry = (input: { const state: RegistryState = { entries, unavailable, changes }; const reconcileWithR = makeReconcile({ state, driversById, parentScope }); - const reconcile: ProviderInstanceRegistryMutatorShape["reconcile"] = (configMap) => + const reconcile: ProviderInstanceRegistryMutator["Service"]["reconcile"] = (configMap) => reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); // Hydrate the initial configMap synchronously so callers can read // `listInstances` immediately after this effect completes. yield* reconcile(input.configMap); - const registry: ProviderInstanceRegistryShape = { + const registry = ProviderInstanceRegistry.of({ getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), listInstances: Ref.get(entries).pipe( Effect.map( @@ -395,9 +448,9 @@ export const makeProviderInstanceRegistry = (input: { get subscribeChanges() { return PubSub.subscribe(changes); }, - }; + }); - const mutator: ProviderInstanceRegistryMutatorShape = { reconcile }; + const mutator = ProviderInstanceRegistryMutator.of({ reconcile }); return { registry, mutator }; }); @@ -409,15 +462,15 @@ export const makeProviderInstanceRegistry = (input: { * wiring up the settings watcher. * * Only exposes the public registry tag — hot-reload consumers should use - * `ProviderInstanceRegistryMutableLayer` (below) or the hydration layer. + * `mutableLayer` (below) or the hydration layer. */ -export const ProviderInstanceRegistryLayer = (input: { +export const layer = (input: { readonly drivers: ReadonlyArray>; readonly configMap: ProviderInstanceConfigMap; }): Layer.Layer => Layer.effect( ProviderInstanceRegistry, - makeProviderInstanceRegistry(input).pipe(Effect.map((built) => built.registry)), + make(input).pipe(Effect.map((built) => built.registry)), ) as Layer.Layer; /** @@ -426,12 +479,12 @@ export const ProviderInstanceRegistryLayer = (input: { * changes. Tests that exercise the mutator directly can pair this Layer * with a test-local `ServerSettingsService`. */ -export const ProviderInstanceRegistryMutableLayer = (input: { +export const mutableLayer = (input: { readonly drivers: ReadonlyArray>; readonly configMap: ProviderInstanceConfigMap; }): Layer.Layer => Layer.effectContext( - makeProviderInstanceRegistry(input).pipe( + make(input).pipe( Effect.map(({ registry, mutator }) => Context.make(ProviderInstanceRegistry, registry).pipe( Context.add(ProviderInstanceRegistryMutator, mutator), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts similarity index 96% rename from apps/server/src/provider/Layers/ProviderRegistry.test.ts rename to apps/server/src/provider/ProviderRegistry.test.ts index b3ab1145495..790be3e37ef 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -30,25 +30,21 @@ import { deepMerge } from "@t3tools/shared/Struct"; import { createModelCapabilities } from "@t3tools/shared/model"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; -import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; -import * as OpenCodeRuntime from "../opencodeRuntime.ts"; -import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; -import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { - haveProvidersChanged, - mergeProviderSnapshot, - mergeProviderSnapshots, - ProviderRegistryLive, - selectProvidersByKind, -} from "./ProviderRegistry.ts"; -import * as ServerConfig from "../../config.ts"; -import * as ServerSettingsModule from "../../serverSettings.ts"; -import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; -import type { ProviderInstance } from "../ProviderDriver.ts"; -import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; -import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; + checkCodexProviderStatus, + type CodexAppServerProviderSnapshot, +} from "./Layers/CodexProvider.ts"; +import { checkClaudeProviderStatus } from "./Layers/ClaudeProvider.ts"; +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; +import { ProviderInstanceRegistryHydrationLive } from "./Layers/ProviderInstanceRegistryHydration.ts"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerSettingsModule from "../serverSettings.ts"; +import { readProviderStatusCache, resolveProviderStatusCachePath } from "./providerStatusCache.ts"; +import type { ProviderInstance } from "./ProviderDriver.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); @@ -471,7 +467,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); }); - describe("ProviderRegistryLive", () => { + describe("ProviderRegistry", () => { it("treats equal provider snapshots as unchanged", () => { const providers = [ { @@ -502,7 +498,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }, ] as const satisfies ReadonlyArray; - assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); + assert.strictEqual(ProviderRegistry.haveProvidersChanged(providers, [...providers]), false); }); it("preserves previously discovered provider models when a refresh returns none", () => { @@ -540,9 +536,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te models: [], } satisfies ServerProvider; - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [...previousProvider.models], + ); }); it("fills missing capabilities from the previous provider snapshot", () => { @@ -589,9 +586,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ], } satisfies ServerProvider; - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [...previousProvider.models], + ); }); it.effect("does not run provider probes during layer construction", () => @@ -650,10 +648,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( + ProviderRegistry.layer.pipe( Layer.provideMerge(instanceRegistryLayer), Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-background-refresh-", }), ), @@ -718,8 +716,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te models: [], } satisfies ServerProvider; - const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); - const persistedProviders = selectProvidersByKind( + const mergedProviders = ProviderRegistry.mergeProviderSnapshots(previousProviders, [ + refreshedCursor, + ]); + const persistedProviders = ProviderRegistry.selectProvidersByKind( mergedProviders, new Set([ProviderDriverKind.make("cursor")]), ); @@ -805,10 +805,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( + ProviderRegistry.layer.pipe( Layer.provideMerge(instanceRegistryLayer), Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-merged-persist-", }), ), @@ -902,10 +902,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( + ProviderRegistry.layer.pipe( Layer.provideMerge(instanceRegistryLayer), Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-refresh-failure-", }), ), @@ -1009,10 +1009,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( + ProviderRegistry.layer.pipe( Layer.provideMerge(instanceRegistryLayer), Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-sync-failure-", }), ), @@ -1102,13 +1102,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( + const providerRegistryLayer = ProviderRegistry.layer.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), @@ -1119,7 +1119,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCodeRuntime.layer), // NO spawner mock — `ChildProcessSpawner` is supplied by the // outer `NodeServices.layer` on `it.layer(...)` and will // genuinely spawn a subprocess. The missing-binary ENOENT is @@ -1194,13 +1194,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( + const providerRegistryLayer = ProviderRegistry.layer.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), @@ -1211,7 +1211,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { spawnedCommands.push((command as { readonly command: string }).command); @@ -1315,13 +1315,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( + const providerRegistryLayer = ProviderRegistry.layer.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), @@ -1332,7 +1332,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.provideMerge(NodeServices.layer), ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( @@ -1376,13 +1376,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te let cursorSpawned = false; const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( + const providerRegistryLayer = ProviderRegistry.layer.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), @@ -1393,7 +1393,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { if (command === "agent") { diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts new file mode 100644 index 00000000000..593d856c9e7 --- /dev/null +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -0,0 +1,741 @@ +/** + * ProviderRegistry aggregates per-instance snapshot streams into a + * single materialized list. + * + * Historically this Layer composed four per-kind Live Layers + * (`CodexProviderLive`, `ClaudeProviderLive`, …) that each exposed a + * `ServerProviderShape`. Those Lives were deleted during the driver / + * instance refactor — every driver now carries its `snapshot: ServerProviderShape` + * bundled onto the `ProviderInstance` the registry produces. + * + * Each configured instance (including multi-instance setups like + * `codex_personal` + `codex_work`) contributes one `ProviderSnapshotSource`, + * keyed by `instanceId`. Instances whose driver is unavailable or whose + * config failed to decode are merged from `instanceRegistry.listUnavailable` + * as shadow snapshots so the UI can render their exact unavailable reason. + * + * Cache paths on disk are now keyed by `instanceId`. Because + * `defaultInstanceIdForDriver(kind) === kind` for built-in kinds, existing + * `.json` files remain the on-disk location for that driver's default + * instance. Identity-less legacy cache contents are ignored and replaced by + * the first live refresh. + * + * @module ProviderRegistry + */ +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderUpdateState, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as Semaphore from "effect/Semaphore"; + +import * as Config from "../config.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import { + hydrateCachedProvider, + isCachedProviderCorrelated, + orderProviderSnapshots, + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "./providerStatusCache.ts"; +import type { ProviderInstance } from "./ProviderDriver.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilities, +} from "./providerMaintenance.ts"; +import type { ProviderSnapshotSource } from "./builtInProviderCatalog.ts"; + +export type ProviderMaintenanceActionKind = "update"; + +export class ProviderRegistry extends Context.Service< + ProviderRegistry, + { + /** + * Read the latest snapshot for every configured instance. Multiple + * snapshots can share a driver kind and are distinguished by instance id. + */ + readonly getProviders: Effect.Effect>; + + /** + * Refresh every provider, or the default instance for one driver kind. + * + * @deprecated Prefer `refreshInstance` for new call sites. + */ + readonly refresh: ( + provider?: ProviderDriverKind, + ) => Effect.Effect>; + + /** + * Refresh one configured instance. Unknown ids resolve to the current + * cached list to preserve the legacy transport behavior. + */ + readonly refreshInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect>; + + /** + * Resolve maintenance capabilities for a live instance, falling back to + * manual-only capabilities when that instance is unavailable. + */ + readonly getProviderMaintenanceCapabilitiesForInstance: ( + instanceId: ProviderInstanceId, + provider: ProviderDriverKind, + ) => Effect.Effect; + + /** + * Apply volatile maintenance state for one instance. This state is not + * persisted; update actions are projected onto `ServerProvider.updateState`. + */ + readonly setProviderMaintenanceActionState: (input: { + readonly instanceId: ProviderInstanceId; + readonly action: ProviderMaintenanceActionKind; + readonly state: ServerProviderUpdateState | null; + }) => Effect.Effect>; + + /** Emits the full materialized provider list after each aggregated change. */ + readonly streamChanges: Stream.Stream>; + } +>()("t3/provider/ProviderRegistry") {} + +const loadProviders = ( + providerSources: ReadonlyArray, +): Effect.Effect> => + Effect.forEach( + providerSources, + (providerSource) => + providerSource.getSnapshot.pipe( + Effect.flatMap((snapshot) => correlateSnapshotWithSource(providerSource, snapshot)), + ), + { + concurrency: "unbounded", + }, + ); + +const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) => + makeManualOnlyProviderMaintenanceCapabilities({ + provider, + packageName: null, + }); + +const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => + (model.capabilities?.optionDescriptors?.length ?? 0) > 0; + +const mergeProviderModels = ( + previousModels: ReadonlyArray, + nextModels: ReadonlyArray, +): ReadonlyArray => { + if (nextModels.length === 0 && previousModels.length > 0) { + return previousModels; + } + + const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); + const mergedModels = nextModels.map((model) => { + const previousModel = previousBySlug.get(model.slug); + if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { + return model; + } + return { + ...model, + capabilities: previousModel.capabilities, + }; + }); + const nextSlugs = new Set(nextModels.map((model) => model.slug)); + return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]; +}; + +export const mergeProviderSnapshot = ( + previousProvider: ServerProvider | undefined, + nextProvider: ServerProvider, +): ServerProvider => + !previousProvider + ? nextProvider + : { + ...nextProvider, + models: mergeProviderModels(previousProvider.models, nextProvider.models), + }; + +export const mergeProviderSnapshots = ( + previousProviders: ReadonlyArray, + nextProviders: ReadonlyArray, +): ReadonlyArray => { + const mergedProviders = new Map( + previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), + ); + + for (const provider of nextProviders) { + mergedProviders.set( + snapshotInstanceKey(provider), + mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), + ); + } + + return orderProviderSnapshots([...mergedProviders.values()]); +}; + +export const selectProvidersByKind = ( + providers: ReadonlyArray, + providerKinds: ReadonlySet, +): ReadonlyArray => + providers.filter((provider) => providerKinds.has(provider.driver)); + +export const haveProvidersChanged = ( + previousProviders: ReadonlyArray, + nextProviders: ReadonlyArray, +): boolean => !Equal.equals(previousProviders, nextProviders); + +const correlateSnapshotWithSource = ( + source: ProviderSnapshotSource, + snapshot: ServerProvider, +): Effect.Effect => { + if (snapshot.instanceId !== source.instanceId) { + return Effect.die( + new Error( + `Provider snapshot instance mismatch: source '${source.instanceId}' emitted '${snapshot.instanceId}'.`, + ), + ); + } + if (snapshot.driver !== source.driverKind) { + return Effect.die( + new Error( + `Provider snapshot driver mismatch for instance '${source.instanceId}': source '${source.driverKind}' emitted '${snapshot.driver}'.`, + ), + ); + } + return Effect.succeed(snapshot); +}; + +/** + * Key a snapshot for aggregation and persistence. Snapshot sources + * must be correlated by instance id before reaching this map; missing + * identities are defects, not runtime routing fallbacks. + */ +const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { + return provider.instanceId; +}; + +// Project a live `ProviderInstance` into the aggregator's consumption +// shape. Each call re-captures the instance's `snapshot` closures, so +// after `ProviderInstanceRegistry` rebuilds an instance (e.g. because +// its settings changed), a fresh source rides the new PubSub instead +// of a closed one. +const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ + instanceId: instance.instanceId, + driverKind: instance.driverKind, + getSnapshot: instance.snapshot.getSnapshot, + refresh: instance.snapshot.refresh, + streamChanges: instance.snapshot.streamChanges, +}); + +export const make = Effect.gen(function* () { + const instanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const config = yield* Config.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for + // coalesced updates across every instance. + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded>(), + PubSub.shutdown, + ); + + // Boot-only: hydrate `providersRef` from the on-disk per-instance + // cache so the UI has something to render during the first refresh. + // Instances added post-boot skip this path; their first entry in + // `providersRef` comes from the reactive `syncLiveSources` pass + // below. + const bootInstances = yield* instanceRegistry.listInstances; + const bootSources = bootInstances.map(buildSnapshotSource); + const fallbackProviders = yield* loadProviders(bootSources); + const fallbackByInstance = new Map(); + for (let index = 0; index < fallbackProviders.length; index++) { + const provider = fallbackProviders[index]; + const source = bootSources[index]; + if (provider === undefined || source === undefined) { + continue; + } + fallbackByInstance.set(source.instanceId, provider); + } + + const cachedProviders = yield* Effect.forEach( + bootSources, + (source) => + Effect.gen(function* () { + // One cache file per configured instance. For the default + // instance of a built-in kind the path equals `.json` — + // identical to the legacy filename. We still require the cache + // payload to carry matching instance id + driver kind; old + // identity-less payloads are discarded and the awaited refresh + // below repopulates the cache. + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: source.instanceId, + }).pipe(Effect.provideService(Path.Path, path)); + const fallbackProvider = fallbackByInstance.get(source.instanceId); + if (fallbackProvider === undefined) { + return undefined; + } + return yield* readProviderStatusCache(filePath).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.flatMap((cachedProvider) => { + if (cachedProvider === undefined) { + return Effect.void.pipe(Effect.as(undefined as ServerProvider | undefined)); + } + const correlation = { + cachedProvider, + fallbackProvider, + } as const; + if (!isCachedProviderCorrelated(correlation)) { + return Effect.logWarning("provider status cache identity mismatch, ignoring", { + path: filePath, + instanceId: source.instanceId, + cachedInstanceId: cachedProvider.instanceId ?? null, + driver: source.driverKind, + cachedDriver: cachedProvider.driver ?? null, + }).pipe(Effect.as(undefined as ServerProvider | undefined)); + } + return Effect.succeed(hydrateCachedProvider(correlation)); + }), + ); + }), + { concurrency: "unbounded" }, + ).pipe( + Effect.map((providers) => + orderProviderSnapshots( + providers.filter((provider): provider is ServerProvider => provider !== undefined), + ), + ), + ); + const providersRef = yield* Ref.make>(cachedProviders); + const maintenanceActionStatesRef = yield* Ref.make< + ReadonlyMap + >(new Map()); + + // Live-source registry — the dynamic counterpart to the boot-time + // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` + // reference is used for identity equality so "no-op" reconciles + // (settings unchanged) skip re-subscribing + re-probing. + const liveSubsRef = yield* Ref.make>(new Map()); + // Serialize `syncLiveSources` so a rapid burst of reconciles doesn't + // interleave two passes clobbering each other's fiber bookkeeping. + const syncSemaphore = yield* Semaphore.make(1); + + const getLiveSources: Effect.Effect> = Ref.get( + liveSubsRef, + ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource))); + + const persistProvider = (provider: ServerProvider) => + Effect.gen(function* () { + // Persist every instance — the file name is the instance id, so + // multi-instance setups (e.g. `codex_personal`, `codex_work`) each + // get their own cache. We resolve the path fresh so snapshots + // produced by newly-added instances post-boot still land on disk + // without the aggregator holding a stale `cachePathByInstance` + // entry. + const key = snapshotInstanceKey(provider); + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: key, + }).pipe(Effect.provideService(Path.Path, path)); + yield* writeProviderStatusCache({ filePath, provider }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.tapError(Effect.logError), + Effect.ignore, + ); + }); + + const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* ( + provider: ServerProvider, + ) { + const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef); + const updateState = maintenanceActionStates.get(provider.instanceId)?.update; + if (!updateState) { + const { updateState: _updateState, ...providerWithoutUpdateState } = provider; + return providerWithoutUpdateState; + } + return { + ...provider, + updateState, + }; + }); + + const upsertProviders = Effect.fn("upsertProviders")(function* ( + nextProviders: ReadonlyArray, + options?: { + readonly publish?: boolean; + readonly persist?: boolean; + readonly replace?: boolean; + }, + ) { + const nextProvidersWithUpdateState = yield* Effect.forEach( + nextProviders, + applyProviderUpdateState, + { + concurrency: "unbounded", + }, + ); + const [previousProviders, providers, providersToPersist] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const mergedProviders = new Map( + previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), + ); + const updatedKeys = new Set(); + + for (const provider of nextProvidersWithUpdateState) { + const key = snapshotInstanceKey(provider); + updatedKeys.add(key); + mergedProviders.set( + key, + options?.replace === true + ? provider + : mergeProviderSnapshot(mergedProviders.get(key), provider), + ); + } + + const providers = orderProviderSnapshots([...mergedProviders.values()]); + const providersToPersist = providers.filter((provider) => + updatedKeys.has(snapshotInstanceKey(provider)), + ); + return [[previousProviders, providers, providersToPersist] as const, providers]; + }, + ); + + if (haveProvidersChanged(previousProviders, providers)) { + if (options?.persist !== false) { + yield* Effect.forEach(providersToPersist, persistProvider, { + concurrency: "unbounded", + discard: true, + }); + } + if (options?.publish !== false) { + yield* PubSub.publish(changesPubSub, providers); + } + } + + return providers; + }); + + const syncProvider = Effect.fn("syncProvider")(function* ( + provider: ServerProvider, + options?: { + readonly publish?: boolean; + }, + ) { + return yield* upsertProviders([provider], options); + }); + + const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( + function* (input: { + readonly instanceId: ProviderInstanceId; + readonly action: "update"; + readonly state: ServerProviderUpdateState | null; + }) { + yield* Ref.update(maintenanceActionStatesRef, (previous) => { + const previousActions = previous.get(input.instanceId); + const nextActions = { ...previousActions }; + if (input.state === null || input.state.status === "idle") { + delete nextActions[input.action]; + } else { + nextActions[input.action] = input.state; + } + + const next = new Map(previous); + if (Object.keys(nextActions).length === 0) { + next.delete(input.instanceId); + } else { + next.set(input.instanceId, nextActions); + } + return next; + }); + + const existingProviders = yield* Ref.get(providersRef); + const matchingProvider = existingProviders.find( + (candidate) => candidate.instanceId === input.instanceId, + ); + if (!matchingProvider) { + return existingProviders; + } + + const nextProvider = yield* applyProviderUpdateState(matchingProvider); + return yield* upsertProviders([nextProvider], { + persist: false, + }); + }, + ); + + const refreshOneSource = Effect.fn("refreshOneSource")(function* ( + providerSource: ProviderSnapshotSource, + ) { + return yield* providerSource.refresh.pipe( + Effect.flatMap((nextProvider) => + correlateSnapshotWithSource(providerSource, nextProvider).pipe( + Effect.flatMap(syncProvider), + ), + ), + ); + }); + + const refreshAll = Effect.fn("refreshAll")(function* () { + const sources = yield* getLiveSources; + return yield* Effect.forEach(sources, (source) => refreshOneSource(source), { + concurrency: "unbounded", + discard: true, + }).pipe(Effect.andThen(Ref.get(providersRef))); + }); + + const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) { + if (provider === undefined) { + return yield* refreshAll(); + } + // Kind-scoped refreshes target the default instance for that driver. + const defaultInstanceId = defaultInstanceIdForDriver(provider); + const sources = yield* getLiveSources; + const providerSource = sources.find((candidate) => candidate.instanceId === defaultInstanceId); + if (!providerSource) { + return yield* Ref.get(providersRef); + } + return yield* refreshOneSource(providerSource); + }); + + const refreshInstance = Effect.fn("refreshInstance")(function* (instanceId: ProviderInstanceId) { + const sources = yield* getLiveSources; + const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); + if (!providerSource) { + return yield* Ref.get(providersRef); + } + return yield* refreshOneSource(providerSource); + }); + + const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( + "getProviderMaintenanceCapabilitiesForInstance", + )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) { + const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find( + (candidate) => candidate.instanceId === instanceId, + ); + return ( + instance?.snapshot.maintenanceCapabilities ?? + makeManualProviderMaintenanceCapabilities(provider) + ); + }); + + /** + * Diff the aggregator's live-source set against the current + * `ProviderInstanceRegistry` and: + * - subscribe to each newly-added or rebuilt instance's + * `streamChanges` (so periodic + enrichment refreshes land in + * `providersRef`); + * - read each newly-added/rebuilt instance's current snapshot after + * subscribing, closing the race with its independently-running + * background startup probe; + * - prune `providersRef` of instances that no longer exist. + * + * Provider refreshes are owned by each managed provider and never run + * on this layer's construction path. Consumers see cached or pending + * snapshots immediately, then receive live probe results through the + * already-attached change stream. + * + * Per-instance subscription fibers are not tracked explicitly. When + * a rebuilt instance's old child scope closes, its PubSub shuts + * down and our `Stream.runForEach` fiber exits naturally. + */ + const syncLiveSources = syncSemaphore.withPermits(1)( + Effect.gen(function* () { + const instances = yield* instanceRegistry.listInstances; + const unavailableProviders = yield* instanceRegistry.listUnavailable; + const nextByInstance = new Map( + instances.map((instance) => [instance.instanceId, instance] as const), + ); + const knownInstanceIds = new Set(nextByInstance.keys()); + for (const provider of unavailableProviders) { + knownInstanceIds.add(snapshotInstanceKey(provider)); + } + const previousSubs = yield* Ref.get(liveSubsRef); + + // Carry over subscriptions for instances whose identity is + // unchanged (reconcile treated them as no-op). Instances that + // disappeared, or were rebuilt with a different reference, + // fall through to the "newly-added" branch below. + const carriedOver = new Map(); + for (const [instanceId, previousInstance] of previousSubs) { + const nextInstance = nextByInstance.get(instanceId); + if (nextInstance !== undefined && nextInstance === previousInstance) { + carriedOver.set(instanceId, previousInstance); + } + } + + // Collect new/rebuilt instances in `nextByInstance` insertion + // order (which preserves settings-author order). + const newlyAdded: Array = []; + for (const [instanceId, instance] of nextByInstance) { + if (carriedOver.has(instanceId)) { + continue; + } + newlyAdded.push([instanceId, instance] as const); + } + + // Fork long-lived subscriptions to each new/rebuilt instance's + // change stream before reading its current snapshot. If the + // driver's own initial probe finishes during this sync, either + // the current read or the active subscriber observes the result. + for (const [, instance] of newlyAdded) { + const source = buildSnapshotSource(instance); + yield* Stream.runForEach(source.streamChanges, (provider) => + correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)), + ).pipe(Effect.forkScoped); + } + yield* Effect.yieldNow; + + // Snapshot current state without starting a probe. Managed providers + // launch their startup refresh independently, so this closes the + // subscription race without putting external work on the registry + // or HTTP server construction path. + yield* Effect.forEach( + newlyAdded, + ([, instance]) => + Effect.gen(function* () { + const source = buildSnapshotSource(instance); + const provider = yield* source.getSnapshot; + yield* correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)); + }).pipe(Effect.ignoreCause({ log: true })), + { concurrency: "unbounded", discard: true }, + ); + yield* upsertProviders(unavailableProviders, { + persist: false, + replace: true, + }); + + const nextSubs = new Map(carriedOver); + for (const [instanceId, instance] of newlyAdded) { + nextSubs.set(instanceId, instance); + } + yield* Ref.set(liveSubsRef, nextSubs); + + // Drop aggregator state for instances that have disappeared — + // otherwise the UI would keep rendering ghosts. + const [previousProviders, providers] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const providers = orderProviderSnapshots( + previousProviders.filter((provider) => + knownInstanceIds.has(snapshotInstanceKey(provider)), + ), + ); + return [[previousProviders, providers] as const, providers]; + }, + ); + if (haveProvidersChanged(previousProviders, providers)) { + yield* PubSub.publish(changesPubSub, providers); + } + yield* Ref.update(maintenanceActionStatesRef, (previous) => { + const next = new Map(previous); + for (const instanceId of previous.keys()) { + if (!knownInstanceIds.has(instanceId)) { + next.delete(instanceId); + } + } + return next; + }); + }), + ); + const syncLiveSourcesAndContinue = syncLiveSources.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + return Effect.logError("provider registry instance sync failed; keeping subscription alive", { + cause: Cause.pretty(cause), + }); + }), + ); + + // Seed `providersRef` with the boot-time fallback snapshots so + // consumers calling `getProviders` immediately after layer build see + // a populated list — even before the first `syncLiveSources` refresh + // resolves. Cached snapshots (already in `providersRef`) merge with + // these via `upsertProviders` so on-disk state wins where present + // and pending fallbacks fill the gaps. + yield* upsertProviders(fallbackProviders, { publish: false }); + // Subscribe to registry mutations BEFORE running the initial sync. + // `subscribeChanges` acquires the dequeue synchronously in this + // fibre; the subscription is active the instant this `yield*` + // returns. Forking the consumer loop later cannot lose a publish + // because no publish can reach a not-yet-subscribed dequeue. + // + // (Contrast with the pre-fix code that did + // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. + // `Stream.fromPubSub` defers `PubSub.subscribe` to stream start, + // and `forkScoped` only schedules the fibre — so a reconcile that + // published between "fibre scheduled" and "fibre starts running" + // was dropped, which made any settings change that replaced an + // instance never propagate to the aggregator's `providersRef`.) + // Subscribe to registry mutations BEFORE running the initial sync. + // `subscribeChanges` acquires the `PubSub.Subscription` synchronously + // in this fibre; the subscription is registered with the PubSub the + // instant this `yield*` returns, so any subsequent publish is + // buffered in the subscription regardless of when the consumer + // fibre below actually starts running. + // + // (Contrast with the pre-fix code that did + // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. + // `instanceRegistry.streamChanges` is `Stream.fromPubSub(changes)`, + // which defers `PubSub.subscribe` to stream start. `forkScoped` only + // schedules the consumer fibre — so a reconcile that published + // between "fibre scheduled" and "fibre starts running + subscribes" + // was dropped, which made any settings change that replaced an + // instance never propagate to the aggregator's `providersRef`.) + const instanceChanges = yield* instanceRegistry.subscribeChanges; + // Initial sync attaches subscriptions and snapshots current state for + // every instance present at boot. Provider probes are already running in + // their managed background fibers and never block this layer. + yield* syncLiveSources; + // React to registry mutations — instance added / removed / rebuilt. + // `Stream.fromSubscription` builds a stream over the pre-acquired + // subscription rather than subscribing on stream start, which is + // what closes the race. + yield* Stream.runForEach( + Stream.fromSubscription(instanceChanges), + () => syncLiveSourcesAndContinue, + ).pipe(Effect.forkScoped); + + const recoverRefreshFailure = Effect.fn("recoverRefreshFailure")(function* ( + cause: Cause.Cause, + ) { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.interrupt; + } + yield* Effect.logError("provider registry refresh failed; preserving cached providers", { + cause: Cause.pretty(cause), + }); + return yield* Ref.get(providersRef); + }); + + return ProviderRegistry.of({ + getProviders: Ref.get(providersRef), + refresh: (provider?: ProviderDriverKind) => + refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshInstance: (instanceId: ProviderInstanceId) => + refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + getProviderMaintenanceCapabilitiesForInstance, + setProviderMaintenanceActionState, + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + }); +}); + +export const layer = Layer.effect(ProviderRegistry, make); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/ProviderService.test.ts similarity index 97% rename from apps/server/src/provider/Layers/ProviderService.test.ts rename to apps/server/src/provider/ProviderService.test.ts index ccbbce1759f..5bcc103b4f1 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/ProviderService.test.ts @@ -41,23 +41,24 @@ import { ProviderUnsupportedError, ProviderValidationError, type ProviderAdapterError, -} from "../Errors.ts"; -import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; -import * as ProviderService from "../Services/ProviderService.ts"; -import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts"; -import { makeProviderServiceLive } from "./ProviderService.ts"; +} from "./Errors.ts"; +import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderService from "./ProviderService.ts"; +import * as ProviderSessionDirectory from "./ProviderSessionDirectory.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; -import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive, SqlitePersistenceMemory, -} from "../../persistence/Layers/Sqlite.ts"; -import * as ServerSettings from "../../serverSettings.ts"; -import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; -import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; +} from "../persistence/Layers/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; + +const makeProviderServiceLive = (options?: ProviderService.ProviderServiceOptions) => + Layer.effect(ProviderService.ProviderService, ProviderService.make(options)); +import { makeAdapterRegistryMock } from "./testUtils/providerAdapterRegistryMock.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); @@ -284,7 +285,7 @@ function makeProviderServiceLayer() { const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const directoryLayer = ProviderSessionDirectory.layer.pipe(Layer.provide(runtimeRepositoryLayer)); const layer = it.layer( Layer.mergeAll( @@ -337,7 +338,9 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const directoryLayer = ProviderSessionDirectory.layer.pipe( + Layer.provide(runtimeRepositoryLayer), + ); const providerLayer = Layer.mergeAll( makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), @@ -397,7 +400,9 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const directoryLayer = ProviderSessionDirectory.layer.pipe( + Layer.provide(runtimeRepositoryLayer), + ); const providerLayer = makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), @@ -479,7 +484,7 @@ it.effect( const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); - const directoryLayer = ProviderSessionDirectoryLive.pipe( + const directoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); const providerLayer = makeProviderServiceLive().pipe( @@ -551,7 +556,9 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const directoryLayer = ProviderSessionDirectory.layer.pipe( + Layer.provide(runtimeRepositoryLayer), + ); const providerLayer = makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), @@ -596,7 +603,9 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const directoryLayer = ProviderSessionDirectory.layer.pipe( + Layer.provide(runtimeRepositoryLayer), + ); const providerLayer = makeProviderServiceLive({ canonicalEventLogger: { filePath: "memory://provider-canonical-events", @@ -656,7 +665,9 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const directoryLayer = ProviderSessionDirectory.layer.pipe( + Layer.provide(runtimeRepositoryLayer), + ); yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; @@ -728,7 +739,7 @@ it.effect( [ProviderDriverKind.make("codex")]: firstCodex.adapter, }); - const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + const firstDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( @@ -787,7 +798,7 @@ it.effect( const secondRegistry = makeAdapterRegistryMock({ [ProviderDriverKind.make("codex")]: secondCodex.adapter, }); - const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( + const secondDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( @@ -1298,7 +1309,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const firstRegistry = makeAdapterRegistryMock({ [ProviderDriverKind.make("claudeAgent")]: firstClaude.adapter, }); - const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + const firstDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( @@ -1336,7 +1347,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const secondRegistry = makeAdapterRegistryMock({ [ProviderDriverKind.make("claudeAgent")]: secondClaude.adapter, }); - const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( + const secondDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( @@ -1404,7 +1415,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const firstRegistry = makeAdapterRegistryMock({ [ProviderDriverKind.make("claudeAgent")]: firstClaude.adapter, }); - const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + const firstDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( @@ -1437,7 +1448,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const secondRegistry = makeAdapterRegistryMock({ [ProviderDriverKind.make("claudeAgent")]: secondClaude.adapter, }); - const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( + const secondDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( diff --git a/apps/server/src/provider/ProviderService.ts b/apps/server/src/provider/ProviderService.ts new file mode 100644 index 00000000000..53fd2308b35 --- /dev/null +++ b/apps/server/src/provider/ProviderService.ts @@ -0,0 +1,1155 @@ +/** + * ProviderService - Cross-provider session and turn service. + * + * Routes validated transport/API calls to provider adapters through + * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a + * unified provider event stream for subscribers. + * + * It does not implement provider protocol details (adapter concern). + * + * @module ProviderService + */ +import { + ModelSelection, + NonNegativeInt, + ThreadId, + ProviderInterruptTurnInput, + ProviderRespondToRequestInput, + ProviderRespondToUserInputInput, + ProviderSendTurnInput, + ProviderSessionStartInput, + ProviderStopSessionInput, + type ProviderInstanceId, + type ProviderDriverKind, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as Stream from "effect/Stream"; + +import { + increment, + providerMetricAttributes, + providerRuntimeEventsTotal, + providerSessionsTotal, + providerTurnDuration, + providerTurnsTotal, + providerTurnMetricAttributes, + withMetrics, +} from "../observability/Metrics.ts"; +import { + type ProviderAdapterError, + type ProviderServiceError, + ProviderValidationError, +} from "./Errors.ts"; +import type { + ProviderAdapterCapabilities, + ProviderAdapterShape, +} from "./Services/ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderSessionDirectory from "./ProviderSessionDirectory.ts"; +import { type EventNdjsonLogger } from "./Layers/EventNdjsonLogger.ts"; +import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; +import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; +import * as McpProviderSession from "../mcp/McpProviderSession.ts"; +import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; +const isModelSelection = Schema.is(ModelSelection); + +export class ProviderService extends Context.Service< + ProviderService, + { + /** Start a provider session for a thread. */ + readonly startSession: ( + threadId: ThreadId, + input: ProviderSessionStartInput, + ) => Effect.Effect; + + /** Send a turn through the adapter bound to the session. */ + readonly sendTurn: ( + input: ProviderSendTurnInput, + ) => Effect.Effect; + + /** Interrupt a running provider turn. */ + readonly interruptTurn: ( + input: ProviderInterruptTurnInput, + ) => Effect.Effect; + + /** Respond to a provider approval request. */ + readonly respondToRequest: ( + input: ProviderRespondToRequestInput, + ) => Effect.Effect; + + /** Respond to a structured provider user-input request. */ + readonly respondToUserInput: ( + input: ProviderRespondToUserInputInput, + ) => Effect.Effect; + + /** Stop a provider session. */ + readonly stopSession: ( + input: ProviderStopSessionInput, + ) => Effect.Effect; + + /** Aggregate the active sessions reported by all registered adapters. */ + readonly listSessions: () => Effect.Effect>; + + /** Read capabilities for the adapter bound to an instance. */ + readonly getCapabilities: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + + /** Read routing metadata for a configured provider instance. */ + readonly getInstanceInfo: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + + /** Roll back provider conversation state by a number of turns. */ + readonly rollbackConversation: (input: { + readonly threadId: ThreadId; + readonly numTurns: number; + }) => Effect.Effect; + + /** + * Canonical provider runtime event stream. ProviderService owns the + * per-instance fan-out rather than delegating it to a separate event bus. + */ + readonly streamEvents: Stream.Stream; + } +>()("t3/provider/ProviderService") {} + +/** + * Hook for tests that want to override the canonical event logger pulled + * from `ProviderEventLoggers`. Production wiring leaves this undefined and + * reads the logger off the tag. + */ +export interface ProviderServiceOptions { + readonly canonicalEventLogger?: EventNdjsonLogger; +} + +const ProviderRollbackConversationInput = Schema.Struct({ + threadId: ThreadId, + numTurns: NonNegativeInt, +}); + +function toValidationError( + operation: string, + issue: string, + cause?: unknown, +): ProviderValidationError { + return new ProviderValidationError({ + operation, + issue, + ...(cause !== undefined ? { cause } : {}), + }); +} + +const decodeInputOrValidationError = (input: { + readonly operation: string; + readonly schema: S; + readonly payload: unknown; +}) => { + const decodeProviderRequestInput = Schema.decodeUnknownEffect(input.schema); + return decodeProviderRequestInput(input.payload).pipe( + Effect.mapError( + (schemaError) => + new ProviderValidationError({ + operation: input.operation, + issue: SchemaIssue.makeFormatterDefault()(schemaError.issue), + cause: schemaError, + }), + ), + ); +}; + +function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "stopped" | "error" { + switch (session.status) { + case "connecting": + return "starting"; + case "error": + return "error"; + case "closed": + return "stopped"; + case "ready": + case "running": + default: + return "running"; + } +} + +function toRuntimePayloadFromSession( + session: ProviderSession, + extra?: { + readonly modelSelection?: unknown; + readonly lastRuntimeEvent?: string; + readonly lastRuntimeEventAt?: string; + }, +): Record { + return { + cwd: session.cwd ?? null, + model: session.model ?? null, + activeTurnId: session.activeTurnId ?? null, + lastError: session.lastError ?? null, + ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), + ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), + ...(extra?.lastRuntimeEventAt !== undefined + ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } + : {}), + }; +} + +function readPersistedModelSelection( + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], +): ModelSelection | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined; + return isModelSelection(raw) ? raw : undefined; +} + +function readPersistedCwd( + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], +): string | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const rawCwd = "cwd" in runtimePayload ? runtimePayload.cwd : undefined; + if (typeof rawCwd !== "string") return undefined; + const trimmed = rawCwd.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +const dieOnMissingBindingInstanceId = ( + operation: string, + payload: { + readonly providerInstanceId?: ProviderInstanceId | undefined; + readonly provider?: ProviderDriverKind | undefined; + }, +): ProviderInstanceId => { + if (payload.providerInstanceId !== undefined) { + return payload.providerInstanceId; + } + throw new Error( + payload.provider + ? `${operation}: provider instance id is required for provider '${payload.provider}'.` + : `${operation}: provider instance id is required.`, + ); +}; + +const correlateRuntimeEventWithInstance = ( + source: { + readonly instanceId: ProviderInstanceId; + readonly provider: ProviderDriverKind; + }, + event: ProviderRuntimeEvent, +): ProviderRuntimeEvent => { + if (event.provider !== source.provider) { + throw new Error( + `ProviderService.streamEvents: provider instance '${source.instanceId}' is backed by driver '${source.provider}' but emitted driver '${event.provider}'.`, + ); + } + if (event.providerInstanceId !== undefined && event.providerInstanceId !== source.instanceId) { + throw new Error( + `ProviderService.streamEvents: provider instance '${source.instanceId}' emitted event for instance '${event.providerInstanceId}'.`, + ); + } + return { ...event, providerInstanceId: source.instanceId }; +}; + +export const make = Effect.fn("ProviderService.make")(function* (options?: ProviderServiceOptions) { + const analytics = yield* Effect.service(AnalyticsService.AnalyticsService); + const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + // Options-provided logger wins (test overrides); otherwise we take whatever + // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical + // log writer is attached", which downstream code already handles as a + // no-op. + const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical; + + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const runtimeEventPubSub = yield* PubSub.unbounded(); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => + McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( + Effect.tap((credential) => + credential + ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)) + : Effect.void, + ), + ); + const clearMcpSession = (threadId: ThreadId) => + McpSessionRegistry.revokeActiveMcpThread(threadId).pipe( + Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))), + ); + + const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Effect.succeed(event).pipe( + Effect.tap((canonicalEvent) => + canonicalEventLogger + ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId) + : Effect.void, + ), + Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), + Effect.asVoid, + ); + + const requireBindingInstanceId = ( + operation: string, + payload: { + readonly providerInstanceId?: ProviderInstanceId | undefined; + readonly provider?: ProviderDriverKind | undefined; + }, + ): Effect.Effect => + payload.providerInstanceId !== undefined + ? Effect.succeed(payload.providerInstanceId) + : Effect.fail( + toValidationError( + operation, + payload.provider + ? `Provider instance id is required for provider '${payload.provider}'.` + : "Provider instance id is required.", + ), + ); + + const upsertSessionBinding = ( + session: ProviderSession, + threadId: ThreadId, + extra?: { + readonly modelSelection?: unknown; + readonly lastRuntimeEvent?: string; + readonly lastRuntimeEventAt?: string; + }, + ) => + Effect.gen(function* () { + const providerInstanceId = yield* requireBindingInstanceId( + "ProviderService.upsertSessionBinding", + session, + ); + yield* directory.upsert({ + threadId, + provider: session.provider, + providerInstanceId, + runtimeMode: session.runtimeMode, + status: toRuntimeStatus(session), + ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), + runtimePayload: toRuntimePayloadFromSession(session, extra), + }); + }); + + const processRuntimeEvent = ( + source: { + readonly instanceId: ProviderInstanceId; + readonly provider: ProviderDriverKind; + }, + event: ProviderRuntimeEvent, + ): Effect.Effect => + Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe( + Effect.flatMap((canonicalEvent) => + increment(providerRuntimeEventsTotal, { + provider: canonicalEvent.provider, + eventType: canonicalEvent.type, + }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), + ), + ); + + // `subscribedAdapters` is our source-of-truth for "which instance adapters + // are currently wired into the runtime event bus". It both tracks the set + // of live subscriptions (so `reconcileInstanceSubscriptions` can diff and + // fork only the *new* or *rebuilt* ones) and serves as the dynamic adapter + // list consumed by `stopStaleSessionsForThread`, `listSessions`, and + // `runStopAll` — replacing the pre-Slice-D startup snapshot so hot-added + // instances become visible to those call sites as soon as settings edits + // land. + const subscribedAdapters = yield* Ref.make( + new Map>(), + ); + + const getAdapterEntries = Ref.get(subscribedAdapters).pipe( + Effect.map((map) => Array.from(map.entries())), + ); + + // Rebuild the map of id → adapter from the registry and fork a new event + // subscription for every instance that is either brand new or whose adapter + // identity changed (indicating the underlying `ProviderInstance` was torn + // down and rebuilt by `ProviderInstanceRegistry.reconcile`). Orphaned + // fibers for removed/replaced instances exit on their own because their + // adapter's `streamEvents` source terminates when the old scope closes. + const reconcileInstanceSubscriptions = Effect.gen(function* () { + const previous = yield* Ref.get(subscribedAdapters); + const currentIds = yield* registry.listInstances(); + const next = new Map>(); + for (const id of currentIds) { + const adapterOption = yield* registry + .getByInstance(id) + .pipe(Effect.tapError(Effect.logWarning), Effect.option); + if (Option.isNone(adapterOption)) continue; + const adapter = adapterOption.value; + next.set(id, adapter); + if (previous.get(id) !== adapter) { + yield* Stream.runForEach(adapter.streamEvents, (event) => + processRuntimeEvent( + { + instanceId: id, + provider: adapter.provider, + }, + event, + ), + ).pipe(Effect.forkScoped); + } + } + yield* Ref.set(subscribedAdapters, next); + }); + + const instanceChanges = yield* registry.subscribeChanges; + yield* reconcileInstanceSubscriptions; + yield* Stream.runForEach( + Stream.fromSubscription(instanceChanges), + () => reconcileInstanceSubscriptions, + ).pipe(Effect.forkScoped); + + const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: { + readonly binding: ProviderSessionDirectory.ProviderRuntimeBinding; + readonly operation: string; + }) { + const bindingInstanceId = yield* requireBindingInstanceId(input.operation, input.binding); + yield* Effect.annotateCurrentSpan({ + "provider.operation": "recover-session", + "provider.kind": input.binding.provider, + "provider.instance_id": bindingInstanceId, + "provider.thread_id": input.binding.threadId, + }); + return yield* Effect.gen(function* () { + const adapter = yield* registry.getByInstance(bindingInstanceId); + const hasResumeCursor = + input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; + const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); + if (hasActiveSession) { + const activeSessions = yield* adapter.listSessions(); + const existing = activeSessions.find( + (session) => session.threadId === input.binding.threadId, + ); + if (existing) { + yield* upsertSessionBinding( + { ...existing, providerInstanceId: bindingInstanceId }, + input.binding.threadId, + ); + yield* analytics.record("provider.session.recovered", { + provider: existing.provider, + strategy: "adopt-existing", + hasResumeCursor: existing.resumeCursor !== undefined, + }); + return { adapter, session: existing } as const; + } + } + + if (!hasResumeCursor) { + return yield* toValidationError( + input.operation, + `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`, + ); + } + + const persistedCwd = readPersistedCwd(input.binding.runtimePayload); + const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); + + yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); + const resumed = yield* adapter + .startSession({ + threadId: input.binding.threadId, + provider: input.binding.provider, + providerInstanceId: bindingInstanceId, + ...(persistedCwd ? { cwd: persistedCwd } : {}), + ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), + ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), + runtimeMode: input.binding.runtimeMode ?? "full-access", + }) + .pipe(Effect.onError(() => clearMcpSession(input.binding.threadId))); + if (resumed.provider !== adapter.provider) { + yield* clearMcpSession(input.binding.threadId); + return yield* toValidationError( + input.operation, + `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, + ); + } + + yield* upsertSessionBinding( + { ...resumed, providerInstanceId: bindingInstanceId }, + input.binding.threadId, + ); + yield* analytics.record("provider.session.recovered", { + provider: resumed.provider, + strategy: "resume-thread", + hasResumeCursor: resumed.resumeCursor !== undefined, + }); + return { adapter, session: resumed } as const; + }).pipe( + withMetrics({ + counter: providerSessionsTotal, + attributes: providerMetricAttributes(input.binding.provider, { + operation: "recover", + }), + }), + ); + }); + + const resolveRoutableSession = Effect.fn("resolveRoutableSession")(function* (input: { + readonly threadId: ThreadId; + readonly operation: string; + readonly allowRecovery: boolean; + }) { + const bindingOption = yield* directory.getBinding(input.threadId); + const binding = Option.getOrUndefined(bindingOption); + if (!binding) { + return yield* toValidationError( + input.operation, + `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, + ); + } + const instanceId = yield* requireBindingInstanceId(input.operation, binding); + const adapter = yield* registry.getByInstance(instanceId); + + const hasRequestedSession = yield* adapter.hasSession(input.threadId); + if (hasRequestedSession) { + return { + adapter, + instanceId, + threadId: input.threadId, + isActive: true, + } as const; + } + + if (!input.allowRecovery) { + return { + adapter, + instanceId, + threadId: input.threadId, + isActive: false, + } as const; + } + + const recovered = yield* recoverSessionForThread({ + binding, + operation: input.operation, + }); + return { + adapter: recovered.adapter, + instanceId, + threadId: input.threadId, + isActive: true, + } as const; + }); + + const stopStaleSessionsForThread = Effect.fn("stopStaleSessionsForThread")(function* (input: { + readonly threadId: ThreadId; + readonly currentInstanceId: ProviderInstanceId; + }) { + const currentAdapters = yield* getAdapterEntries; + yield* Effect.forEach( + currentAdapters, + ([instanceId, adapter]) => + instanceId === input.currentInstanceId + ? Effect.void + : Effect.gen(function* () { + const hasSession = yield* adapter.hasSession(input.threadId); + if (!hasSession) { + return; + } + + yield* adapter.stopSession(input.threadId).pipe( + Effect.tap(() => + analytics.record("provider.session.stopped", { + provider: adapter.provider, + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("provider.session.stop-stale-failed", { + threadId: input.threadId, + provider: adapter.provider, + cause, + }), + ), + ); + }), + { discard: true }, + ); + }); + + const startSession: ProviderService["Service"]["startSession"] = Effect.fn("startSession")( + function* (threadId, rawInput) { + const parsed = yield* decodeInputOrValidationError({ + operation: "ProviderService.startSession", + schema: ProviderSessionStartInput, + payload: rawInput, + }); + + const resolvedInstanceId = yield* requireBindingInstanceId( + "ProviderService.startSession", + parsed, + ); + let metricProvider = parsed.provider ?? String(resolvedInstanceId); + yield* Effect.annotateCurrentSpan({ + "provider.operation": "start-session", + "provider.instance_id": resolvedInstanceId, + "provider.thread_id": threadId, + "provider.runtime_mode": parsed.runtimeMode, + }); + return yield* Effect.gen(function* () { + const instanceInfo = yield* registry.getInstanceInfo(resolvedInstanceId); + const resolvedProvider = instanceInfo.driverKind; + metricProvider = resolvedProvider; + if (parsed.provider !== undefined && parsed.provider !== resolvedProvider) { + return yield* toValidationError( + "ProviderService.startSession", + `Provider instance '${resolvedInstanceId}' belongs to driver '${resolvedProvider}', not '${parsed.provider}'.`, + ); + } + const input = { + ...parsed, + threadId, + provider: resolvedProvider, + }; + if (!instanceInfo.enabled) { + return yield* toValidationError( + "ProviderService.startSession", + `Provider instance '${resolvedInstanceId}' is disabled in T3 Code settings.`, + ); + } + const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + const effectiveResumeCursor = + input.resumeCursor ?? + (persistedBinding?.providerInstanceId === resolvedInstanceId + ? persistedBinding.resumeCursor + : undefined); + const effectiveCwd = + input.cwd ?? + (persistedBinding?.providerInstanceId === resolvedInstanceId + ? readPersistedCwd(persistedBinding.runtimePayload) + : undefined); + yield* Effect.annotateCurrentSpan({ + "provider.kind": resolvedProvider, + "provider.resume_cursor.source": + input.resumeCursor !== undefined + ? "request" + : effectiveResumeCursor !== undefined && + persistedBinding?.providerInstanceId === resolvedInstanceId + ? "persisted" + : "none", + "provider.resume_cursor.present": effectiveResumeCursor !== undefined, + "provider.cwd.source": + input.cwd !== undefined + ? "request" + : effectiveCwd !== undefined && + persistedBinding?.providerInstanceId === resolvedInstanceId + ? "persisted" + : "none", + "provider.cwd.effective": effectiveCwd ?? "", + }); + const adapter = yield* registry.getByInstance(resolvedInstanceId); + yield* prepareMcpSession(threadId, resolvedInstanceId); + const session = yield* adapter + .startSession({ + ...input, + providerInstanceId: resolvedInstanceId, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), + }) + .pipe(Effect.onError(() => clearMcpSession(threadId))); + + if (session.provider !== adapter.provider) { + yield* clearMcpSession(threadId); + return yield* toValidationError( + "ProviderService.startSession", + `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, + ); + } + const sessionWithInstance = { + ...session, + providerInstanceId: resolvedInstanceId, + }; + + yield* stopStaleSessionsForThread({ + threadId, + currentInstanceId: resolvedInstanceId, + }); + yield* upsertSessionBinding(sessionWithInstance, threadId, { + modelSelection: input.modelSelection, + }); + yield* analytics.record("provider.session.started", { + provider: sessionWithInstance.provider, + runtimeMode: input.runtimeMode, + hasResumeCursor: sessionWithInstance.resumeCursor !== undefined, + hasCwd: typeof effectiveCwd === "string" && effectiveCwd.trim().length > 0, + hasModel: + typeof input.modelSelection?.model === "string" && + input.modelSelection.model.trim().length > 0, + }); + + return sessionWithInstance; + }).pipe( + withMetrics({ + counter: providerSessionsTotal, + attributes: () => + providerMetricAttributes(metricProvider, { + operation: "start", + }), + }), + ); + }, + ); + + const sendTurn: ProviderService["Service"]["sendTurn"] = Effect.fn("sendTurn")( + function* (rawInput) { + const parsed = yield* decodeInputOrValidationError({ + operation: "ProviderService.sendTurn", + schema: ProviderSendTurnInput, + payload: rawInput, + }); + + const input = { + ...parsed, + attachments: parsed.attachments ?? [], + }; + if (!input.input && input.attachments.length === 0) { + return yield* toValidationError( + "ProviderService.sendTurn", + "Either input text or at least one attachment is required", + ); + } + yield* Effect.annotateCurrentSpan({ + "provider.operation": "send-turn", + "provider.thread_id": input.threadId, + "provider.interaction_mode": input.interactionMode, + "provider.attachment_count": input.attachments.length, + }); + let metricProvider = "unknown"; + let metricModel = input.modelSelection?.model; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.sendTurn", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + metricModel = input.modelSelection?.model; + yield* Effect.annotateCurrentSpan({ + "provider.kind": routed.adapter.provider, + ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), + }); + const turn = yield* routed.adapter.sendTurn(input); + yield* directory.upsert({ + threadId: input.threadId, + provider: routed.adapter.provider, + providerInstanceId: routed.instanceId, + status: "running", + ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), + runtimePayload: { + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + activeTurnId: turn.turnId, + lastRuntimeEvent: "provider.sendTurn", + lastRuntimeEventAt: yield* nowIso, + }, + }); + yield* analytics.record("provider.turn.sent", { + provider: routed.adapter.provider, + model: input.modelSelection?.model, + interactionMode: input.interactionMode, + attachmentCount: input.attachments.length, + hasInput: typeof input.input === "string" && input.input.trim().length > 0, + }); + return turn; + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + timer: providerTurnDuration, + attributes: () => + providerTurnMetricAttributes({ + provider: metricProvider, + model: metricModel, + extra: { + operation: "send", + }, + }), + }), + ); + }, + ); + + const interruptTurn: ProviderService["Service"]["interruptTurn"] = Effect.fn("interruptTurn")( + function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.interruptTurn", + schema: ProviderInterruptTurnInput, + payload: rawInput, + }); + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.interruptTurn", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "interrupt-turn", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + "provider.turn_id": input.turnId, + }); + yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); + yield* analytics.record("provider.turn.interrupted", { + provider: routed.adapter.provider, + }); + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "interrupt", + }), + }), + ); + }, + ); + + const respondToRequest: ProviderService["Service"]["respondToRequest"] = Effect.fn( + "respondToRequest", + )(function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.respondToRequest", + schema: ProviderRespondToRequestInput, + payload: rawInput, + }); + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.respondToRequest", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "respond-to-request", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + "provider.request_id": input.requestId, + }); + yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); + yield* analytics.record("provider.request.responded", { + provider: routed.adapter.provider, + decision: input.decision, + }); + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "approval-response", + }), + }), + ); + }); + + const respondToUserInput: ProviderService["Service"]["respondToUserInput"] = Effect.fn( + "respondToUserInput", + )(function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.respondToUserInput", + schema: ProviderRespondToUserInputInput, + payload: rawInput, + }); + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.respondToUserInput", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "respond-to-user-input", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + "provider.request_id": input.requestId, + }); + yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers); + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "user-input-response", + }), + }), + ); + }); + + const stopSession: ProviderService["Service"]["stopSession"] = Effect.fn("stopSession")( + function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.stopSession", + schema: ProviderStopSessionInput, + payload: rawInput, + }); + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.stopSession", + allowRecovery: false, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "stop-session", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + }); + if (routed.isActive) { + yield* routed.adapter.stopSession(routed.threadId); + } + yield* clearMcpSession(input.threadId); + yield* directory.upsert({ + threadId: input.threadId, + provider: routed.adapter.provider, + providerInstanceId: routed.instanceId, + status: "stopped", + runtimePayload: { + activeTurnId: null, + }, + }); + yield* analytics.record("provider.session.stopped", { + provider: routed.adapter.provider, + }); + }).pipe( + withMetrics({ + counter: providerSessionsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "stop", + }), + }), + ); + }, + ); + + const listSessions: ProviderService["Service"]["listSessions"] = Effect.fn("listSessions")( + function* () { + const currentAdapters = yield* getAdapterEntries; + const sessionsByProvider = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => + adapter.listSessions().pipe( + Effect.map((sessions) => + sessions.map((session) => ({ + ...session, + providerInstanceId: instanceId, + })), + ), + ), + ); + const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); + const persistedBindings = yield* directory.listThreadIds().pipe( + Effect.flatMap((threadIds) => + Effect.forEach( + threadIds, + (threadId) => + directory + .getBinding(threadId) + .pipe( + Effect.orElseSucceed(() => + Option.none(), + ), + ), + { concurrency: "unbounded" }, + ), + ), + Effect.orElseSucceed( + () => [] as Array>, + ), + ); + const bindingsByThreadId = new Map< + ThreadId, + ProviderSessionDirectory.ProviderRuntimeBinding + >(); + for (const bindingOption of persistedBindings) { + const binding = Option.getOrUndefined(bindingOption); + if (binding) { + bindingsByThreadId.set(binding.threadId, binding); + } + } + + const sessions: ProviderSession[] = []; + for (const session of activeSessions) { + const binding = bindingsByThreadId.get(session.threadId); + if (!binding) { + sessions.push(session); + continue; + } + + const overrides: { + resumeCursor?: ProviderSession["resumeCursor"]; + runtimeMode?: ProviderSession["runtimeMode"]; + providerInstanceId?: ProviderSession["providerInstanceId"]; + } = {}; + overrides.providerInstanceId = dieOnMissingBindingInstanceId( + "ProviderService.listSessions", + binding, + ); + if (binding.provider !== session.provider) { + return yield* Effect.die( + new Error( + `ProviderService.listSessions: thread '${session.threadId}' is active on provider '${session.provider}' but persisted binding names provider '${binding.provider}'.`, + ), + ); + } + if (overrides.providerInstanceId !== session.providerInstanceId) { + return yield* Effect.die( + new Error( + `ProviderService.listSessions: thread '${session.threadId}' is active on provider instance '${session.providerInstanceId}' but persisted binding names '${overrides.providerInstanceId}'.`, + ), + ); + } + if (session.resumeCursor === undefined && binding.resumeCursor !== undefined) { + overrides.resumeCursor = binding.resumeCursor; + } + if (binding.runtimeMode !== undefined) { + overrides.runtimeMode = binding.runtimeMode; + } + sessions.push(Object.assign({}, session, overrides)); + } + return sessions; + }, + ); + + const getCapabilities: ProviderService["Service"]["getCapabilities"] = (instanceId) => + registry.getByInstance(instanceId).pipe(Effect.map((adapter) => adapter.capabilities)); + + const getInstanceInfo: ProviderService["Service"]["getInstanceInfo"] = (instanceId) => + registry.getInstanceInfo(instanceId); + + const rollbackConversation: ProviderService["Service"]["rollbackConversation"] = Effect.fn( + "rollbackConversation", + )(function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.rollbackConversation", + schema: ProviderRollbackConversationInput, + payload: rawInput, + }); + if (input.numTurns === 0) { + return; + } + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.rollbackConversation", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "rollback-conversation", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + "provider.rollback_turns": input.numTurns, + }); + yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns); + yield* analytics.record("provider.conversation.rolled_back", { + provider: routed.adapter.provider, + turns: input.numTurns, + }); + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "rollback", + }), + }), + ); + }); + + const runStopAll = Effect.fn("runStopAll")(function* () { + const threadIds = yield* directory.listThreadIds(); + const currentAdapters = yield* getAdapterEntries; + const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => + adapter.listSessions().pipe( + Effect.map((sessions) => + sessions.map((session) => ({ + ...session, + providerInstanceId: instanceId, + })), + ), + ), + ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); + yield* Effect.forEach(activeSessions, (session) => + Effect.flatMap(nowIso, (lastRuntimeEventAt) => + upsertSessionBinding(session, session.threadId, { + lastRuntimeEvent: "provider.stopAll", + lastRuntimeEventAt, + }), + ), + ).pipe(Effect.asVoid); + yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); + yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); + McpProviderSession.clearAllMcpProviderSessions(); + const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach(bindings, (binding) => + Effect.gen(function* () { + const providerInstanceId = dieOnMissingBindingInstanceId( + "ProviderService.stopAll", + binding, + ); + return yield* directory.upsert({ + threadId: binding.threadId, + provider: binding.provider, + providerInstanceId, + status: "stopped", + runtimePayload: { + activeTurnId: null, + lastRuntimeEvent: "provider.stopAll", + lastRuntimeEventAt: yield* nowIso, + }, + }); + }), + ).pipe(Effect.asVoid); + yield* analytics.record("provider.sessions.stopped_all", { + sessionCount: threadIds.length, + }); + yield* analytics.flush; + }); + + yield* Effect.addFinalizer(() => + runStopAll().pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to stop provider service", { cause: Cause.pretty(cause) }), + ), + ), + ); + + return ProviderService.of({ + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + getCapabilities, + getInstanceInfo, + rollbackConversation, + // Each access creates a fresh PubSub subscription so that multiple + // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each + // independently receive all runtime events. + get streamEvents(): ProviderService["Service"]["streamEvents"] { + return Stream.fromPubSub(runtimeEventPubSub); + }, + }); +}); + +export const layer = Layer.effect(ProviderService, make()); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/ProviderSessionDirectory.test.ts similarity index 91% rename from apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts rename to apps/server/src/provider/ProviderSessionDirectory.test.ts index 079b7f10ebf..9def269b314 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/ProviderSessionDirectory.test.ts @@ -15,16 +15,17 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { makeSqlitePersistenceLive, SqlitePersistenceMemory, -} from "../../persistence/Layers/Sqlite.ts"; -import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; -import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; -import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +} from "../persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import * as ProviderSessionDirectory from "./ProviderSessionDirectory.ts"; function makeDirectoryLayer(persistenceLayer: Layer.Layer) { - const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(persistenceLayer), + ); return Layer.mergeAll( runtimeRepositoryLayer, - ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), + ProviderSessionDirectory.layer.pipe(Layer.provide(runtimeRepositoryLayer)), NodeServices.layer, ); } @@ -32,7 +33,7 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { it("upserts and reads thread bindings", () => Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const initialThreadId = ThreadId.make("thread-1"); @@ -79,7 +80,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("persists runtime fields and merges payload updates", () => Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = ThreadId.make("thread-runtime"); @@ -124,7 +125,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("lists persisted bindings with metadata in oldest-first order", () => Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const olderThreadId = ThreadId.make("thread-runtime-older"); @@ -198,7 +199,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = ThreadId.make("thread-provider-change"); @@ -236,7 +237,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const threadId = ThreadId.make("thread-restart"); yield* Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), threadId, @@ -244,7 +245,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }).pipe(Effect.provide(directoryLayer)); yield* Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const sql = yield* SqlClient.SqlClient; const provider = yield* directory.getProvider(threadId); assert.equal(provider, "codex"); diff --git a/apps/server/src/provider/ProviderSessionDirectory.ts b/apps/server/src/provider/ProviderSessionDirectory.ts new file mode 100644 index 00000000000..4045a5ab157 --- /dev/null +++ b/apps/server/src/provider/ProviderSessionDirectory.ts @@ -0,0 +1,244 @@ +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderInstanceId, + type ProviderSessionRuntimeStatus, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "./Errors.ts"; +import * as ProviderSessionRuntime from "../persistence/Services/ProviderSessionRuntime.ts"; + +export interface ProviderRuntimeBinding { + readonly threadId: ThreadId; + readonly provider: ProviderDriverKind; + /** + * Routing key for the configured provider instance that owns this + * session. The persistence layer promotes legacy null rows before + * exposing bindings; runtime callers must not infer this from `provider`. + */ + readonly providerInstanceId?: ProviderInstanceId; + readonly adapterKey?: string; + readonly status?: ProviderSessionRuntimeStatus; + readonly resumeCursor?: unknown | null; + readonly runtimePayload?: unknown | null; + readonly runtimeMode?: RuntimeMode; +} + +export interface ProviderRuntimeBindingWithMetadata extends ProviderRuntimeBinding { + readonly lastSeenAt: string; +} + +export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; + +export type ProviderSessionDirectoryWriteError = + | ProviderValidationError + | ProviderSessionDirectoryPersistenceError; + +export class ProviderSessionDirectory extends Context.Service< + ProviderSessionDirectory, + { + readonly upsert: ( + binding: ProviderRuntimeBinding, + ) => Effect.Effect; + readonly getProvider: ( + threadId: ThreadId, + ) => Effect.Effect; + readonly getBinding: ( + threadId: ThreadId, + ) => Effect.Effect, ProviderSessionDirectoryReadError>; + readonly listThreadIds: () => Effect.Effect< + ReadonlyArray, + ProviderSessionDirectoryPersistenceError + >; + readonly listBindings: () => Effect.Effect< + ReadonlyArray, + ProviderSessionDirectoryPersistenceError + >; + } +>()("t3/provider/ProviderSessionDirectory") {} + +const decodeProviderDriverKindValue = Schema.decodeUnknownEffect(ProviderDriverKind); + +function toPersistenceError(operation: string) { + return (cause: unknown) => + new ProviderSessionDirectoryPersistenceError({ + operation, + detail: `Failed to execute ${operation}.`, + cause, + }); +} + +function decodeProviderDriverKind( + providerName: string, + operation: string, +): Effect.Effect { + return decodeProviderDriverKindValue(providerName).pipe( + Effect.mapError( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation, + detail: `Unknown persisted provider '${providerName}'.`, + cause, + }), + ), + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function mergeRuntimePayload( + existing: unknown | null, + next: unknown | null | undefined, +): unknown | null { + if (next === undefined) { + return existing ?? null; + } + if (isRecord(existing) && isRecord(next)) { + return { ...existing, ...next }; + } + return next; +} + +function toRuntimeBinding( + runtime: ProviderSessionRuntime.ProviderSessionRuntime, + operation: string, +): Effect.Effect { + return decodeProviderDriverKind(runtime.providerName, operation).pipe( + Effect.map( + (provider) => + ({ + threadId: runtime.threadId, + provider, + providerInstanceId: runtime.providerInstanceId ?? defaultInstanceIdForDriver(provider), + adapterKey: runtime.adapterKey, + runtimeMode: runtime.runtimeMode, + status: runtime.status, + resumeCursor: runtime.resumeCursor, + runtimePayload: runtime.runtimePayload, + lastSeenAt: runtime.lastSeenAt, + }) satisfies ProviderRuntimeBindingWithMetadata, + ), + ); +} + +export const make = Effect.gen(function* () { + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + + const getBinding: ProviderSessionDirectory["Service"]["getBinding"] = (threadId) => + repository.getByThreadId({ threadId }).pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getByThreadId")), + Effect.flatMap((runtime) => + Option.match(runtime, { + onNone: () => Effect.succeed(Option.none()), + onSome: (value) => + toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe( + Effect.map(Option.some), + ), + }), + ), + ); + + const upsert: ProviderSessionDirectory["Service"]["upsert"] = Effect.fn( + "ProviderSessionDirectory.upsert", + )(function* (binding) { + const existing = yield* repository + .getByThreadId({ threadId: binding.threadId }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); + + const existingRuntime = Option.getOrUndefined(existing); + const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; + if (!resolvedThreadId) { + return yield* new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "threadId must be a non-empty string.", + }); + } + + const now = DateTime.formatIso(yield* DateTime.now); + const providerChanged = + existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; + const providerInstanceId = + binding.providerInstanceId ?? (!providerChanged ? existingRuntime?.providerInstanceId : null); + if (providerInstanceId === null || providerInstanceId === undefined) { + return yield* new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "providerInstanceId is required for provider session runtime bindings.", + }); + } + yield* repository + .upsert({ + threadId: resolvedThreadId, + providerName: binding.provider, + providerInstanceId, + adapterKey: + binding.adapterKey ?? + (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); + }); + + const getProvider: ProviderSessionDirectory["Service"]["getProvider"] = (threadId) => + getBinding(threadId).pipe( + Effect.flatMap( + Option.match({ + onSome: (value) => Effect.succeed(value.provider), + onNone: () => + Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.getProvider", + detail: `No persisted provider binding found for thread '${threadId}'.`, + }), + ), + }), + ), + ); + + const listThreadIds: ProviderSessionDirectory["Service"]["listThreadIds"] = () => + repository.list().pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), + Effect.map((rows) => rows.map((row) => row.threadId)), + ); + + const listBindings: ProviderSessionDirectory["Service"]["listBindings"] = () => + repository.list().pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => toRuntimeBinding(row, "ProviderSessionDirectory.listBindings"), + { concurrency: "unbounded" }, + ), + ), + ); + + return ProviderSessionDirectory.of({ + upsert, + getProvider, + getBinding, + listThreadIds, + listBindings, + }); +}); + +export const layer = Layer.effect(ProviderSessionDirectory, make); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/ProviderSessionReaper.test.ts similarity index 89% rename from apps/server/src/provider/Layers/ProviderSessionReaper.test.ts rename to apps/server/src/provider/ProviderSessionReaper.test.ts index e976c183a43..e9e165dc6ba 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/ProviderSessionReaper.test.ts @@ -17,14 +17,13 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; -import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; -import { ProviderValidationError } from "../Errors.ts"; -import { ProviderSessionReaper } from "../Services/ProviderSessionReaper.ts"; -import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; -import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; -import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import { ProviderValidationError } from "./Errors.ts"; +import * as ProviderSessionReaper from "./ProviderSessionReaper.ts"; +import * as ProviderService from "./ProviderService.ts"; +import * as ProviderSessionDirectory from "./ProviderSessionDirectory.ts"; const defaultModelSelection = { instanceId: ProviderInstanceId.make("codex"), @@ -117,7 +116,8 @@ function makeReadModel( describe("ProviderSessionReaper", () => { let runtime: ManagedRuntime.ManagedRuntime< - ProviderSessionReaper | ProviderSessionRuntime.ProviderSessionRuntimeRepository, + | ProviderSessionReaper.ProviderSessionReaper + | ProviderSessionRuntime.ProviderSessionRuntimeRepository, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -137,19 +137,19 @@ describe("ProviderSessionReaper", () => { readonly readModel: ReturnType; readonly stopSessionImplementation?: (input: { readonly threadId: ThreadId; - }) => ReturnType; + }) => ReturnType; }) { const stoppedThreadIds = new Set(); - const stopSession = vi.fn( + const stopSession = vi.fn( (request) => (input.stopSessionImplementation ? input.stopSessionImplementation(request) : Effect.sync(() => { stoppedThreadIds.add(request.threadId); - })) as ReturnType, + })) as ReturnType, ); - const providerService: ProviderServiceShape = { + const providerService: ProviderService.ProviderService["Service"] = { startSession: () => unsupported(), sendTurn: () => unsupported(), interruptTurn: () => unsupported(), @@ -178,18 +178,21 @@ describe("ProviderSessionReaper", () => { const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + const providerSessionDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const layer = makeProviderSessionReaperLive({ - inactivityThresholdMs: 1_000, - sweepIntervalMs: 60_000, - }).pipe( + const layer = Layer.effect( + ProviderSessionReaper.ProviderSessionReaper, + ProviderSessionReaper.make({ + inactivityThresholdMs: 1_000, + sweepIntervalMs: 60_000, + }), + ).pipe( Layer.provideMerge(providerSessionDirectoryLayer), Layer.provideMerge(runtimeRepositoryLayer), - Layer.provideMerge(Layer.succeed(ProviderService, providerService)), + Layer.provideMerge(Layer.succeed(ProviderService.ProviderService, providerService)), Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -257,7 +260,9 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + const reaper = await runtime!.runPromise( + Effect.service(ProviderSessionReaper.ProviderSessionReaper), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); @@ -307,7 +312,9 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + const reaper = await runtime!.runPromise( + Effect.service(ProviderSessionReaper.ProviderSessionReaper), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); await Effect.runPromise(drainFibers); @@ -356,7 +363,9 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + const reaper = await runtime!.runPromise( + Effect.service(ProviderSessionReaper.ProviderSessionReaper), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); await Effect.runPromise(drainFibers); @@ -405,7 +414,9 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + const reaper = await runtime!.runPromise( + Effect.service(ProviderSessionReaper.ProviderSessionReaper), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); await Effect.runPromise(drainFibers); @@ -491,7 +502,9 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + const reaper = await runtime!.runPromise( + Effect.service(ProviderSessionReaper.ProviderSessionReaper), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); @@ -574,7 +587,9 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + const reaper = await runtime!.runPromise( + Effect.service(ProviderSessionReaper.ProviderSessionReaper), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/ProviderSessionReaper.ts similarity index 77% rename from apps/server/src/provider/Layers/ProviderSessionReaper.ts rename to apps/server/src/provider/ProviderSessionReaper.ts index ca396b40596..1a4ea8fbbb3 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/ProviderSessionReaper.ts @@ -1,31 +1,36 @@ +import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; -import { +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderService from "./ProviderService.ts"; +import * as ProviderSessionDirectory from "./ProviderSessionDirectory.ts"; + +export class ProviderSessionReaper extends Context.Service< ProviderSessionReaper, - type ProviderSessionReaperShape, -} from "../Services/ProviderSessionReaper.ts"; -import { ProviderService } from "../Services/ProviderService.ts"; + { + readonly start: () => Effect.Effect; + } +>()("t3/provider/ProviderSessionReaper") {} const DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000; const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000; -export interface ProviderSessionReaperLiveOptions { +export interface ProviderSessionReaperOptions { readonly inactivityThresholdMs?: number; readonly sweepIntervalMs?: number; } -const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) => +export const make = (options?: ProviderSessionReaperOptions) => Effect.gen(function* () { - const providerService = yield* ProviderService; - const directory = yield* ProviderSessionDirectory; - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const providerService = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const inactivityThresholdMs = Math.max( 1, @@ -103,19 +108,15 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = } }); - const start: ProviderSessionReaperShape["start"] = () => + const start: ProviderSessionReaper["Service"]["start"] = () => Effect.gen(function* () { yield* Effect.forkScoped( sweep.pipe( Effect.catch((error: unknown) => - Effect.logWarning("provider.session.reaper.sweep-failed", { - error, - }), + Effect.logWarning("provider.session.reaper.sweep-failed", { error }), ), Effect.catchDefect((defect: unknown) => - Effect.logWarning("provider.session.reaper.sweep-defect", { - defect, - }), + Effect.logWarning("provider.session.reaper.sweep-defect", { defect }), ), Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), ), @@ -127,12 +128,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = }); }); - return { - start, - } satisfies ProviderSessionReaperShape; + return ProviderSessionReaper.of({ start }); }); -export const makeProviderSessionReaperLive = (options?: ProviderSessionReaperLiveOptions) => - Layer.effect(ProviderSessionReaper, makeProviderSessionReaper(options)); - -export const ProviderSessionReaperLive = makeProviderSessionReaperLive(); +export const layer = Layer.effect(ProviderSessionReaper, make()); diff --git a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts index 5b755c42eed..c3841eadf2e 100644 --- a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts @@ -1,100 +1,3 @@ -/** - * ProviderAdapterRegistry - Lookup boundary for provider adapter implementations. - * - * Maps a `ProviderInstanceId` (the new per-instance routing key) or a - * `ProviderDriverKind` (legacy single-instance-per-driver key) to the concrete - * adapter service (Codex, Claude, etc). It does not own session lifecycle - * or routing rules; `ProviderService` uses this registry together with - * `ProviderSessionDirectory`. - * - * During the driver/instance migration this tag exposes both flavours: - * - * - `getByInstance` / `listInstances` — new per-instance routing. Callers - * that already know an `instanceId` (threads, sessions, events) - * should prefer these. - * (`defaultInstanceIdForDriver(kind) === kind`), matching the pre-Slice-D - * behaviour. New code should not grow additional callers of the kind-keyed - * methods; they exist so the settings UI, WS refresh RPC, and a handful - * of legacy persisted rows can still be routed during the rollout. - * - * @module ProviderAdapterRegistry - */ -import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; -import type * as PubSub from "effect/PubSub"; -import type * as Scope from "effect/Scope"; -import type * as Stream from "effect/Stream"; - -import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; -import type { ProviderContinuationIdentity } from "../ProviderDriver.ts"; - -export interface ProviderInstanceRoutingInfo { - readonly instanceId: ProviderInstanceId; - readonly driverKind: ProviderDriverKind; - readonly displayName: string | undefined; - readonly accentColor?: string | undefined; - readonly enabled: boolean; - readonly continuationIdentity: ProviderContinuationIdentity; -} - -/** - * ProviderAdapterRegistryShape - Service API for adapter lookup. - */ -export interface ProviderAdapterRegistryShape { - /** - * Resolve the adapter for a specific instance id. Returns - * `ProviderUnsupportedError` if no such instance is currently registered - * (which covers "never configured" *and* "configured but the driver is - * unavailable in this build" — both surface the same failure to callers - * that expect a working adapter). - */ - readonly getByInstance: ( - instanceId: ProviderInstanceId, - ) => Effect.Effect, ProviderUnsupportedError>; - - readonly getInstanceInfo: ( - instanceId: ProviderInstanceId, - ) => Effect.Effect; - - /** - * List all live instance ids. Excludes unavailable/shadow instances — - * callers of this method want something they can pass to `getByInstance`. - */ - readonly listInstances: () => Effect.Effect>; - - /** - * Legacy: list provider kinds whose default instance is currently - * registered. - * - * @deprecated Prefer `listInstances`. Retained for migration-era call - * sites that iterate providers to build UI/metrics. - */ - readonly listProviders: () => Effect.Effect>; - - /** - * Change notification stream mirroring `ProviderInstanceRegistry.streamChanges`. - * Emits one `void` tick whenever the set of live instances changes - * (instance added, removed, or rebuilt after a settings edit). Consumers - * that fan out `adapter.streamEvents` per instance — e.g. `ProviderService`'s - * runtime event bus — re-pull `listInstances` on each tick and fork new - * subscriptions for instances they haven't seen yet. - */ - readonly streamChanges: Stream.Stream; - - /** - * Acquire a change subscription synchronously in the caller's current fiber. - * Consumers that must avoid missing a publish between initial reconciliation - * and watcher startup should use this, then fork `Stream.fromSubscription`. - */ - readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; -} - -/** - * ProviderAdapterRegistry - Service tag for provider adapter lookup. - */ -export class ProviderAdapterRegistry extends Context.Service< - ProviderAdapterRegistry, - ProviderAdapterRegistryShape ->()("t3/provider/Services/ProviderAdapterRegistry") {} +// Compatibility shim for the intentionally excluded orchestration harness. +export { ProviderAdapterRegistry } from "../ProviderAdapterRegistry.ts"; +export type { ProviderInstanceRoutingInfo } from "../ProviderAdapterRegistry.ts"; diff --git a/apps/server/src/provider/Services/ProviderInstanceRegistry.ts b/apps/server/src/provider/Services/ProviderInstanceRegistry.ts deleted file mode 100644 index cfea1142666..00000000000 --- a/apps/server/src/provider/Services/ProviderInstanceRegistry.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * ProviderInstanceRegistry — the single Effect service in the new model. - * - * Owns a `Map` produced by running - * registered driver factories against `ServerSettings.providerInstances`. - * The registry watches settings; when an instance's config changes (or - * the entry disappears), the registry tears down the affected instance's - * scope and rebuilds — that's the entire hot-reload story. - * - * What rest-of-server reads from here: - * - `getInstance(instanceId)` — for routing turn/session calls. - * - `listInstances` — for snapshot aggregation in `ProviderRegistry`. - * - `listUnavailable` — `ServerProvider` shadows for instances whose - * driver is not registered in this build (rollback / fork tolerance). - * - `streamChanges` — coalesced "registry mutated" pings so consumers - * can re-pull lists or re-broadcast. - * - * @module provider/Services/ProviderInstanceRegistry - */ -import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; -import type * as PubSub from "effect/PubSub"; -import type * as Scope from "effect/Scope"; -import type * as Stream from "effect/Stream"; - -import type { ProviderInstance } from "../ProviderDriver.ts"; - -export interface ProviderInstanceRegistryShape { - /** - * Look up one instance by id. Returns `undefined` (not Option) when the - * id is unknown — callers branch on falsy and emit - * `ProviderInstanceNotFoundError`. - */ - readonly getInstance: ( - instanceId: ProviderInstanceId, - ) => Effect.Effect; - /** - * Every available (driver-registered, successfully created) instance, - * in stable settings-author order. - */ - readonly listInstances: Effect.Effect>; - /** - * Wire-shape shadow snapshots for instances whose driver is unknown to - * this build (or whose config failed to decode). Suitable for merging - * directly into `ProviderRegistry` output. - */ - readonly listUnavailable: Effect.Effect>; - /** - * Push notification stream emitted whenever the registry's contents - * change — instance added, removed, or rebuilt. The payload is `void` - * because consumers always want to re-pull `listInstances` / - * `listUnavailable` together. - * - * NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the - * stream starts running, forking a consumer via - * `Stream.runForEach(...).pipe(Effect.forkScoped)` races the next - * publish — the forked fiber may not have subscribed yet when the - * publish lands. Hot-reload consumers that must not miss a publish - * should use `subscribeChanges` below instead, which acquires the - * subscription synchronously in the caller's fiber before the consumer - * loop is forked. - */ - readonly streamChanges: Stream.Stream; - /** - * Acquire a subscription to the registry's change channel synchronously - * in the caller's fiber. Returns a `PubSub.Subscription` whose - * lifetime is scoped to the provided `Scope` (the subscription is - * released when the scope closes). Consumers typically `yield*` this - * in the same fiber that forks their consumer loop, then drain with - * `PubSub.take(subscription)` inside `Effect.forever`. Because the - * subscription is registered with the PubSub before this `yield*` - * returns, no subsequent publish can land in a gap. - * - * This exists because the `ProviderInstanceRegistry` publishes on a - * PubSub and `Stream.fromPubSub` defers subscription until the stream - * starts executing — a consumer that `forkScoped`s the stream - * consumption can miss a publish that lands in the narrow window - * between "fiber scheduled" and "fiber starts running". - */ - readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; -} - -export class ProviderInstanceRegistry extends Context.Service< - ProviderInstanceRegistry, - ProviderInstanceRegistryShape ->()("t3/provider/Services/ProviderInstanceRegistry") {} diff --git a/apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts deleted file mode 100644 index 98d45080237..00000000000 --- a/apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * ProviderInstanceRegistryMutator — internal handle used by the hydration - * layer to reconcile the live registry with a fresh - * `ProviderInstanceConfigMap`. - * - * Kept separate from the public `ProviderInstanceRegistry` service tag so - * downstream consumers (drivers, reactors, `ProviderService`) can only read - * from the registry. Only the hydration layer — which watches - * `ServerSettingsService.streamChanges` and applies diffs — imports this - * tag. - * - * The mutator exposes a single entry point, `reconcile(configMap)`, which: - * - * 1. Diffs the incoming map against the live one keyed by instance id. - * 2. Closes the per-instance `Scope` of every removed or replaced entry - * (tearing down adapter processes, refresh fibres, temp files) BEFORE - * creating the replacement — `reconcile` guarantees "at most one live - * instance per id" at all times. - * 3. Opens a fresh child `Scope` for every added or replaced entry, runs - * the driver's `create`, and stores the resulting `ProviderInstance` - * plus its scope. - * 4. Publishes one `void` tick on the registry's `streamChanges` PubSub at - * the end of the batch — consumers re-pull `listInstances` / - * `listUnavailable`. - * - * `reconcile` is idempotent: calling it with an unchanged config map is a - * no-op (no scope churn, no pubsub emission). - * - * @module provider/Services/ProviderInstanceRegistryMutator - */ -import type { ProviderInstanceConfigMap } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -export interface ProviderInstanceRegistryMutatorShape { - /** - * Bring the live registry in line with the supplied config map. See - * module docs for the add / remove / replace semantics. - * - * The effect never fails: individual driver `create` failures are - * captured as "unavailable" shadow snapshots inside the registry, the - * same way boot-time failures are handled by - * `makeProviderInstanceRegistry`. This keeps settings-watcher loops from - * erroring out on a single bad entry. - */ - readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect; -} - -export class ProviderInstanceRegistryMutator extends Context.Service< - ProviderInstanceRegistryMutator, - ProviderInstanceRegistryMutatorShape ->()("t3/provider/Services/ProviderInstanceRegistryMutator") {} diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b30338..96c841a8ad0 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -1,81 +1,2 @@ -/** - * ProviderRegistry - Provider snapshot service. - * - * Owns provider install/auth/version/model snapshots and exposes the latest - * provider state to transport layers. - * - * @module ProviderRegistry - */ -import type { - ProviderInstanceId, - ProviderDriverKind, - ServerProvider, - ServerProviderUpdateState, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; -import type * as Stream from "effect/Stream"; -import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; - -export type ProviderMaintenanceActionKind = "update"; - -export interface ProviderRegistryShape { - /** - * Read the latest provider snapshots for every configured instance. - * Multiple snapshots may share the same `provider` kind (multiple - * instances of the same driver) and disambiguate via `instanceId`. - */ - readonly getProviders: Effect.Effect>; - - /** - * Refresh all providers, or the default instance of the specified - * kind when supplied. - * - * Retained for back-compat with legacy call sites (WS refresh RPC, - * orchestration metrics). New code should prefer `refreshInstance`. - * - * @deprecated prefer `refreshInstance` for new call sites. - */ - readonly refresh: (provider?: ProviderDriverKind) => Effect.Effect>; - - /** - * Refresh the specific configured instance. Returns the updated snapshot - * list. When the instance id is unknown the call resolves with the - * currently cached list (no error) — matching the legacy `refresh` shim - * behaviour so transport layers don't have to special-case unknowns. - */ - readonly refreshInstance: ( - instanceId: ProviderInstanceId, - ) => Effect.Effect>; - - /** - * Resolve the maintenance capabilities owned by one live provider instance. - * Falls back to manual-only capabilities when the instance is not live. - */ - readonly getProviderMaintenanceCapabilitiesForInstance: ( - instanceId: ProviderInstanceId, - provider: ProviderDriverKind, - ) => Effect.Effect; - - /** - * Apply volatile maintenance-action state to one configured instance. - * This state is never persisted to disk. Today only update actions are - * projected onto `ServerProvider.updateState`; install/auth actions can - * extend this action map without adding driver-scoped APIs. - */ - readonly setProviderMaintenanceActionState: (input: { - readonly instanceId: ProviderInstanceId; - readonly action: ProviderMaintenanceActionKind; - readonly state: ServerProviderUpdateState | null; - }) => Effect.Effect>; - - /** - * Stream of provider snapshot updates — one emission per aggregated - * change. The array contains the full current state. - */ - readonly streamChanges: Stream.Stream>; -} - -export class ProviderRegistry extends Context.Service()( - "t3/provider/Services/ProviderRegistry", -) {} +// Compatibility shim for the intentionally excluded orchestration modules. +export * from "../ProviderRegistry.ts"; diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 4d4cb4fa01a..4477887cd24 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -1,121 +1,5 @@ -/** - * ProviderService - Service interface for provider sessions, turns, and checkpoints. - * - * Acts as the cross-provider facade used by transports (WebSocket/RPC). It - * resolves provider adapters through `ProviderAdapterRegistry`, routes - * session-scoped calls via `ProviderSessionDirectory`, and exposes one unified - * provider event stream to callers. - * - * Uses Effect `Context.Service` for dependency injection and returns typed - * domain errors for validation, session, codex, and checkpoint workflows. - * - * @module ProviderService - */ -import type { - ProviderInterruptTurnInput, - ProviderInstanceId, - ProviderRespondToRequestInput, - ProviderRespondToUserInputInput, - ProviderRuntimeEvent, - ProviderSendTurnInput, - ProviderSession, - ProviderSessionStartInput, - ProviderStopSessionInput, - ThreadId, - ProviderTurnStartResult, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; -import type * as Stream from "effect/Stream"; +// Compatibility shim for the intentionally excluded orchestration modules and harness. +export * from "../ProviderService.ts"; -import type { ProviderServiceError } from "../Errors.ts"; -import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; -import type { ProviderInstanceRoutingInfo } from "./ProviderAdapterRegistry.ts"; - -/** - * ProviderServiceShape - Service API for provider session and turn orchestration. - */ -export interface ProviderServiceShape { - /** - * Start a provider session. - */ - readonly startSession: ( - threadId: ThreadId, - input: ProviderSessionStartInput, - ) => Effect.Effect; - - /** - * Send a provider turn. - */ - readonly sendTurn: ( - input: ProviderSendTurnInput, - ) => Effect.Effect; - - /** - * Interrupt a running provider turn. - */ - readonly interruptTurn: ( - input: ProviderInterruptTurnInput, - ) => Effect.Effect; - - /** - * Respond to a provider approval request. - */ - readonly respondToRequest: ( - input: ProviderRespondToRequestInput, - ) => Effect.Effect; - - /** - * Respond to a provider structured user-input request. - */ - readonly respondToUserInput: ( - input: ProviderRespondToUserInputInput, - ) => Effect.Effect; - - /** - * Stop a provider session. - */ - readonly stopSession: ( - input: ProviderStopSessionInput, - ) => Effect.Effect; - - /** - * List active provider sessions. - * - * Aggregates runtime session lists from all registered adapters. - */ - readonly listSessions: () => Effect.Effect>; - - /** - * Read capabilities for the adapter bound to a configured provider instance. - */ - readonly getCapabilities: ( - instanceId: ProviderInstanceId, - ) => Effect.Effect; - - readonly getInstanceInfo: ( - instanceId: ProviderInstanceId, - ) => Effect.Effect; - - /** - * Roll back provider conversation state by a number of turns. - */ - readonly rollbackConversation: (input: { - readonly threadId: ThreadId; - readonly numTurns: number; - }) => Effect.Effect; - - /** - * Canonical provider runtime event stream. - * - * Fan-out is owned by ProviderService (not by a standalone event-bus service). - */ - readonly streamEvents: Stream.Stream; -} - -/** - * ProviderService - Service tag for provider orchestration. - */ -export class ProviderService extends Context.Service()( - "t3/provider/Services/ProviderService", -) {} +/** @deprecated Use `ProviderService["Service"]` from the canonical module. */ +export type ProviderServiceShape = import("../ProviderService.ts").ProviderService["Service"]; diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts deleted file mode 100644 index f2dd4323f7a..00000000000 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { - ProviderInstanceId, - ProviderDriverKind, - ProviderSessionRuntimeStatus, - RuntimeMode, - ThreadId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { - ProviderSessionDirectoryPersistenceError, - ProviderValidationError, -} from "../Errors.ts"; - -export interface ProviderRuntimeBinding { - readonly threadId: ThreadId; - readonly provider: ProviderDriverKind; - /** - * Routing key for the configured provider instance that owns this - * session. The persistence layer promotes legacy null rows before - * exposing bindings; runtime callers must not infer this from `provider`. - */ - readonly providerInstanceId?: ProviderInstanceId; - readonly adapterKey?: string; - readonly status?: ProviderSessionRuntimeStatus; - readonly resumeCursor?: unknown | null; - readonly runtimePayload?: unknown | null; - readonly runtimeMode?: RuntimeMode; -} - -export interface ProviderRuntimeBindingWithMetadata extends ProviderRuntimeBinding { - readonly lastSeenAt: string; -} - -export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; - -export type ProviderSessionDirectoryWriteError = - | ProviderValidationError - | ProviderSessionDirectoryPersistenceError; - -export interface ProviderSessionDirectoryShape { - readonly upsert: ( - binding: ProviderRuntimeBinding, - ) => Effect.Effect; - - readonly getProvider: ( - threadId: ThreadId, - ) => Effect.Effect; - - readonly getBinding: ( - threadId: ThreadId, - ) => Effect.Effect, ProviderSessionDirectoryReadError>; - - readonly listThreadIds: () => Effect.Effect< - ReadonlyArray, - ProviderSessionDirectoryPersistenceError - >; - - readonly listBindings: () => Effect.Effect< - ReadonlyArray, - ProviderSessionDirectoryPersistenceError - >; -} - -export class ProviderSessionDirectory extends Context.Service< - ProviderSessionDirectory, - ProviderSessionDirectoryShape ->()("t3/provider/Services/ProviderSessionDirectory") {} diff --git a/apps/server/src/provider/Services/ProviderSessionReaper.ts b/apps/server/src/provider/Services/ProviderSessionReaper.ts deleted file mode 100644 index 7c4627eca89..00000000000 --- a/apps/server/src/provider/Services/ProviderSessionReaper.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; -import type * as Scope from "effect/Scope"; - -export interface ProviderSessionReaperShape { - /** - * Start the background provider session reaper within the provided scope. - */ - readonly start: () => Effect.Effect; -} - -export class ProviderSessionReaper extends Context.Service< - ProviderSessionReaper, - ProviderSessionReaperShape ->()("t3/provider/Services/ProviderSessionReaper") {} diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index a83c134d5bd..06f29944be0 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -13,14 +13,12 @@ import { } from "@opencode-ai/sdk/v2"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as P from "effect/Predicate"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Scope from "effect/Scope"; @@ -28,7 +26,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { isWindowsCommandNotFound } from "../processRunner.ts"; +import * as ProcessRunner from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -50,14 +48,19 @@ export interface OpenCodeServerConnection { readonly external: boolean; } -const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; -export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ - readonly operation: string; - readonly cause?: unknown; - readonly detail: string; -}> { - static readonly is = (u: unknown): u is OpenCodeRuntimeError => - P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); +export class OpenCodeRuntimeError extends Schema.TaggedErrorClass()( + "OpenCodeRuntimeError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + static readonly is = Schema.is(OpenCodeRuntimeError); + + override get message(): string { + return `${this.operation}: ${this.detail}`; + } } function encodeJsonStringForDiagnostics(input: unknown): string | undefined { @@ -107,47 +110,50 @@ export interface ParsedOpenCodeModelSlug { readonly modelID: string; } -export interface OpenCodeRuntimeShape { - /** - * Spawns a local OpenCode server process. Its lifetime is bound to the caller's - * `Scope.Scope` — the child is killed automatically when that scope closes. - * Consumers that want a long-lived server must create and hold a scope explicitly - * (see {@link Scope.make}) and close it when done. - */ - readonly startOpenCodeServerProcess: (input: { - readonly binaryPath: string; - readonly environment?: NodeJS.ProcessEnv; - readonly port?: number; - readonly hostname?: string; - readonly timeoutMs?: number; - }) => Effect.Effect; - /** - * Returns a handle to either an externally-managed OpenCode server (when - * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a - * freshly spawned local server whose lifetime is bound to the caller's scope. - */ - readonly connectToOpenCodeServer: (input: { - readonly binaryPath: string; - readonly serverUrl?: string | null; - readonly environment?: NodeJS.ProcessEnv; - readonly port?: number; - readonly hostname?: string; - readonly timeoutMs?: number; - }) => Effect.Effect; - readonly runOpenCodeCommand: (input: { - readonly binaryPath: string; - readonly args: ReadonlyArray; - readonly environment?: NodeJS.ProcessEnv; - }) => Effect.Effect; - readonly createOpenCodeSdkClient: (input: { - readonly baseUrl: string; - readonly directory: string; - readonly serverPassword?: string; - }) => OpencodeClient; - readonly loadOpenCodeInventory: ( - client: OpencodeClient, - ) => Effect.Effect; -} +export class OpenCodeRuntime extends Context.Service< + OpenCodeRuntime, + { + /** + * Spawns a local OpenCode server process. Its lifetime is bound to the caller's + * `Scope.Scope` — the child is killed automatically when that scope closes. + * Consumers that want a long-lived server must create and hold a scope explicitly + * (see {@link Scope.make}) and close it when done. + */ + readonly startOpenCodeServerProcess: (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; + }) => Effect.Effect; + /** + * Returns a handle to either an externally-managed OpenCode server (when + * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a + * freshly spawned local server whose lifetime is bound to the caller's scope. + */ + readonly connectToOpenCodeServer: (input: { + readonly binaryPath: string; + readonly serverUrl?: string | null; + readonly environment?: NodeJS.ProcessEnv; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; + }) => Effect.Effect; + readonly runOpenCodeCommand: (input: { + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; + readonly createOpenCodeSdkClient: (input: { + readonly baseUrl: string; + readonly directory: string; + readonly serverPassword?: string; + }) => OpencodeClient; + readonly loadOpenCodeInventory: ( + client: OpencodeClient, + ) => Effect.Effect; + } +>()("t3/provider/opencodeRuntime") {} function parseServerUrlFromOutput(output: string): string | null { for (const line of output.split("\n")) { @@ -275,14 +281,14 @@ function ensureRuntimeError( : new OpenCodeRuntimeError({ operation, detail, cause }); } -const makeOpenCodeRuntime = Effect.gen(function* () { +export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService.NetService; const hostPlatform = yield* HostProcessPlatform; const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => resolveSpawnCommand(command, args, env ? { env } : {}); - const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => + const runOpenCodeCommand: OpenCodeRuntime["Service"]["runOpenCodeCommand"] = (input) => Effect.gen(function* () { const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); const child = yield* spawner.spawn( @@ -296,7 +302,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { { concurrency: "unbounded" }, ); const exitCode = Number(code); - if (yield* isWindowsCommandNotFound(exitCode, stderr)) { + if (yield* ProcessRunner.isWindowsCommandNotFound(exitCode, stderr)) { return yield* new OpenCodeRuntimeError({ operation: "runOpenCodeCommand", detail: `spawn ${input.binaryPath} ENOENT`, @@ -318,7 +324,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ), ); - const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => + const startOpenCodeServerProcess: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = ( + input, + ) => Effect.gen(function* () { // Bind this server's lifetime to the caller's scope. When the caller's // scope closes, the spawned child is killed and all associated fibers @@ -476,7 +484,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { } satisfies OpenCodeServerProcess; }); - const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { + const connectToOpenCodeServer: OpenCodeRuntime["Service"]["connectToOpenCodeServer"] = ( + input, + ) => { const serverUrl = input.serverUrl?.trim(); if (serverUrl) { // We don't own externally-configured servers — no scope interaction. @@ -502,7 +512,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); }; - const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => + const createOpenCodeSdkClient: OpenCodeRuntime["Service"]["createOpenCodeSdkClient"] = (input) => createOpencodeClient({ baseUrl: input.baseUrl, directory: input.directory, @@ -537,24 +547,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.map((result) => result.data ?? []), ); - const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => + const loadOpenCodeInventory: OpenCodeRuntime["Service"]["loadOpenCodeInventory"] = (client) => Effect.all([loadProviders(client), loadAgents(client)], { concurrency: "unbounded" }).pipe( Effect.map(([providerList, agents]) => ({ providerList, agents })), ); - return { + return OpenCodeRuntime.of({ startOpenCodeServerProcess, connectToOpenCodeServer, runOpenCodeCommand, createOpenCodeSdkClient, loadOpenCodeInventory, - } satisfies OpenCodeRuntimeShape; + }); }); -export class OpenCodeRuntime extends Context.Service()( - "t3/provider/opencodeRuntime", -) {} - -export const OpenCodeRuntimeLive = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( - Layer.provide(NetService.layer), -); +export const layer = Layer.effect(OpenCodeRuntime, make).pipe(Layer.provide(NetService.layer)); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 5ffb69cd5f7..7a3f54a3d49 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -18,7 +18,7 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { makeProviderMaintenanceCapabilities, @@ -177,7 +177,7 @@ function makeRegistry( ); }); - const registry: ProviderRegistryShape = { + const registry: ProviderRegistry.ProviderRegistry["Service"] = { getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), @@ -194,13 +194,13 @@ function makeRegistry( }); } -const makeTestRunner = (registry: ProviderRegistryShape) => +const makeTestRunner = (registry: ProviderRegistry.ProviderRegistry["Service"]) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( ProviderMaintenanceRunner.layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(ProviderRegistry, registry), + Layer.succeed(ProviderRegistry.ProviderRegistry, registry), Layer.succeed(ProviderVersionCache, new Map()), ), ), diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index f04e97a13ec..bfb744f0cd8 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -9,7 +9,6 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -20,7 +19,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts"; import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; @@ -39,26 +38,36 @@ export interface ProviderMaintenanceCommandResult { readonly stderrTruncated: boolean; } -export interface ProviderMaintenanceRunnerShape { - readonly updateProvider: ( - target: - | ProviderDriverKind - | { - readonly provider: ProviderDriverKind; - readonly instanceId?: ProviderInstanceId | undefined; - }, - ) => Effect.Effect; -} - export class ProviderMaintenanceRunner extends Context.Service< ProviderMaintenanceRunner, - ProviderMaintenanceRunnerShape + { + readonly updateProvider: ( + target: + | ProviderDriverKind + | { + readonly provider: ProviderDriverKind; + readonly instanceId?: ProviderInstanceId | undefined; + }, + ) => Effect.Effect; + } >()("t3/provider/providerMaintenanceRunner") {} -class ProviderMaintenanceCommandError extends Data.TaggedError("ProviderMaintenanceCommandError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +class ProviderMaintenanceCommandError extends Schema.TaggedErrorClass()( + "ProviderMaintenanceCommandError", + { + operation: Schema.Literals(["spawn", "collect"]), + command: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const causeMessage = this.cause instanceof Error ? this.cause.message : undefined; + if (this.operation === "spawn") { + return `Failed to run update command ${this.command ?? "unknown"}${causeMessage ? `: ${causeMessage}` : "."}`; + } + return causeMessage ?? "Update command failed to run."; + } +} interface VerifiedProviderRefresh { readonly providers: ReadonlyArray; @@ -81,7 +90,8 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR Effect.mapError( (cause) => new ProviderMaintenanceCommandError({ - message: `Failed to run update command ${input.command}: ${cause.message}`, + operation: "spawn", + command: input.command, cause, }), ), @@ -105,7 +115,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR Effect.mapError( (cause) => new ProviderMaintenanceCommandError({ - message: cause instanceof Error ? cause.message : "Update command failed to run.", + operation: "collect", cause, }), ), @@ -191,7 +201,7 @@ function makeUpdateState(input: { } export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { - const providerRegistry = yield* ProviderRegistry; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const runMaintenanceCommand = (command: string, args: ReadonlyArray) => @@ -277,7 +287,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }), ); - const updateProvider: ProviderMaintenanceRunnerShape["updateProvider"] = Effect.fn( + const updateProvider: ProviderMaintenanceRunner["Service"]["updateProvider"] = Effect.fn( "ProviderMaintenanceRunner.updateProvider", )(function* (target) { const provider = typeof target === "string" ? target : target.provider; diff --git a/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts b/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts index c696a51c37e..cad3e53bb04 100644 --- a/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts @@ -1,5 +1,5 @@ /** - * Test helpers for constructing a `ProviderAdapterRegistryShape` mock from a + * Test helpers for constructing a `ProviderAdapterRegistry["Service"]` mock from a * kind-keyed adapter map. * * Tests historically assembled a `registry` object with only `getByProvider` @@ -26,19 +26,21 @@ import * as Stream from "effect/Stream"; import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -import type { ProviderAdapterRegistryShape } from "../Services/ProviderAdapterRegistry.ts"; +import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; export type KindAdapterMap = Partial< Record> >; /** - * Build a `ProviderAdapterRegistryShape` from a kind-keyed adapter map. + * Build a `ProviderAdapterRegistry["Service"]` from a kind-keyed adapter map. * Every adapter present in the map is addressable via both the legacy * `getByProvider(kind)` path and the new `getByInstance(id)` path (where * `id = defaultInstanceIdForDriver(kind)`). */ -export const makeAdapterRegistryMock = (adapters: KindAdapterMap): ProviderAdapterRegistryShape => { +export const makeAdapterRegistryMock = ( + adapters: KindAdapterMap, +): ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] => { const byInstanceId = new Map>(); for (const [kind, adapter] of Object.entries(adapters)) { if (!adapter) continue; @@ -46,16 +48,17 @@ export const makeAdapterRegistryMock = (adapters: KindAdapterMap): ProviderAdapt byInstanceId.set(defaultInstanceIdForDriver(driverKind), adapter); } - const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => { - const adapter = byInstanceId.get(instanceId); - return adapter - ? Effect.succeed(adapter) - : Effect.fail( - new ProviderUnsupportedError({ - provider: ProviderDriverKind.make(instanceId), - }), - ); - }; + const getByInstance: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"]["getByInstance"] = + (instanceId) => { + const adapter = byInstanceId.get(instanceId); + return adapter + ? Effect.succeed(adapter) + : Effect.fail( + new ProviderUnsupportedError({ + provider: ProviderDriverKind.make(instanceId), + }), + ); + }; return { getByInstance, diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 36598b05900..22fe01f28bd 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -1,4 +1,4 @@ -import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "../ProviderRegistry.ts"; import type { ServerProvider } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -7,7 +7,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMainte export const makeProviderRegistryMock = ( providers: ReadonlyArray = [], -): ProviderRegistryShape => ({ +): ProviderRegistry.ProviderRegistry["Service"] => ({ getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), @@ -18,4 +18,4 @@ export const makeProviderRegistryMock = ( }); export const makeProviderRegistryLayer = (providers: ReadonlyArray = []) => - Layer.succeed(ProviderRegistry, makeProviderRegistryMock(providers)); + Layer.succeed(ProviderRegistry.ProviderRegistry, makeProviderRegistryMock(providers)); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e1daf20ed57..64d4b784e61 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -81,7 +81,7 @@ import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; -import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 81d0013b20c..0d5b1c8a4cf 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -18,12 +18,12 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; -import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; +import * as ProviderSessionDirectory from "./provider/ProviderSessionDirectory.ts"; import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; -import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; -import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; -import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; -import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import * as ProviderAdapterRegistry from "./provider/ProviderAdapterRegistry.ts"; +import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts"; +import * as ProviderService from "./provider/ProviderService.ts"; +import * as ProviderSessionReaper from "./provider/ProviderSessionReaper.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; @@ -50,7 +50,7 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor. import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; -import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; +import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; @@ -165,18 +165,18 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(RuntimeReceiptBusLive), ); -const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( +const ProviderSessionDirectoryLayerLive = ProviderSessionDirectory.layer.pipe( Layer.provide(ProviderSessionRuntime.layer), ); -// `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter +// `ProviderAdapterRegistry.layer` is a facade that resolves kind → adapter // by looking up the default `ProviderInstance` per driver in the instance // registry. Adapter construction itself moved inside each driver's -// `create()`; `ProviderEventLoggersLive` owns the shared native/canonical +// `create()`; `ProviderEventLoggers.layer` owns the shared native/canonical // NDJSON writers and is provided at the outer runtime layer so both // `ProviderService` and the per-instance drivers read the same logger pair. -const ProviderLayerLive = ProviderServiceLive.pipe( - Layer.provide(ProviderAdapterRegistryLive), +const ProviderLayerLive = ProviderService.layer.pipe( + Layer.provide(ProviderAdapterRegistry.layer), Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); @@ -278,7 +278,7 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll( ), ); -const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( +const ProviderRuntimeLayerLive = ProviderSessionReaper.layer.pipe( Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(OrchestrationLayerLive), ); @@ -293,7 +293,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(Keybindings.layer), - Layer.provideMerge(ProviderRegistryLive), + Layer.provideMerge(ProviderRegistry.layer), // The instance registry is the new routing keystone — text generation, // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; @@ -305,13 +305,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `ProviderService` (canonical stream, written after event normalization). // Provided once at the runtime level so every consumer sees the same // logger instances. - Layer.provideMerge(ProviderEventLoggers.ProviderEventLoggersLive), + Layer.provideMerge(ProviderEventLoggers.layer), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old - // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but + // The old provider registry Layer pulled `OpenCodeRuntime.layer` in for itself, but // the rewritten registry reads snapshots off the instance registry and // no longer transitively provides it. Exposing it at the runtime level // keeps a single Live for all opencode consumers. - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b52b577c5b5..eb12c4ded88 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -33,7 +33,7 @@ import * as ServerSettings from "./serverSettings.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; +import * as ProviderSessionReaper from "./provider/ProviderSessionReaper.ts"; import { formatHeadlessServeOutput, formatHostForUrl, diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 558a8663b64..b0aacb20c9b 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -39,7 +39,7 @@ const runtimeMock = { }, }; -const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { startOpenCodeServerProcess: ({ binaryPath }) => Effect.gen(function* () { const index = runtimeMock.state.startCalls.length + 1; @@ -98,7 +98,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { ); }, }, - }) as unknown as ReturnType, + }) as unknown as ReturnType< + OpenCodeRuntime.OpenCodeRuntime["Service"]["createOpenCodeSdkClient"] + >, loadOpenCodeInventory: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index 9bccb9c1fc5..15014e1b768 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -9,7 +9,7 @@ import { ProviderInstanceId } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as TextGeneration from "./TextGeneration.ts"; const makeStubTextGeneration = ( diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index e62a79afe78..6df8a3ff2c4 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import { TextGenerationError } from "@t3tools/contracts"; -import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 554a942d78a..1edd2a787f4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -73,7 +73,7 @@ import { observeRpcStream as instrumentRpcStream, observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; -import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index e6eff1e5c21..529a9956df7 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -40,8 +40,8 @@ const LEGACY_BASELINE = new Map([ ["apps/server/src/provider/Layers/CodexSessionRuntime.test.ts", 5], ["apps/server/src/provider/Layers/CursorAdapter.test.ts", 1], ["apps/server/src/provider/Layers/CursorProvider.test.ts", 4], - ["apps/server/src/provider/Layers/ProviderService.test.ts", 2], - ["apps/server/src/provider/Layers/ProviderSessionReaper.test.ts", 21], + ["apps/server/src/provider/ProviderService.test.ts", 2], + ["apps/server/src/provider/ProviderSessionReaper.test.ts", 21], ["apps/server/src/relay/AgentAwarenessRelay.test.ts", 4], ["apps/server/src/server.test.ts", 1], ["apps/web/src/cloud/dpop.test.ts", 2], From 84207d07a4a3ed048c653efcd02eed67e1b53754 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:12:18 -0700 Subject: [PATCH 02/20] Tighten provider Effect service refactor Co-authored-by: codex --- .../OrchestrationEngineHarness.integration.ts | 31 ++++---- .../providerService.integration.test.ts | 4 +- .../Layers/CheckpointReactor.test.ts | 9 +-- .../orchestration/Layers/CheckpointReactor.ts | 4 +- .../Layers/ProviderCommandReactor.test.ts | 31 ++++---- .../Layers/ProviderCommandReactor.ts | 8 +- .../Layers/ProviderRuntimeIngestion.test.ts | 9 +-- .../Layers/ProviderRuntimeIngestion.ts | 4 +- .../Layers/ThreadDeletionReactor.ts | 4 +- .../provider/Layers/OpenCodeProvider.test.ts | 25 ++++++ .../provider/Layers/ProviderEventLoggers.ts | 6 -- .../src/provider/Layers/ProviderService.ts | 11 --- .../Layers/ProviderSessionDirectory.ts | 6 -- .../src/provider/ProviderAdapterRegistry.ts | 35 ++++++--- .../src/provider/ProviderInstanceRegistry.ts | 45 +++++++---- apps/server/src/provider/ProviderRegistry.ts | 42 +++++----- .../src/provider/ProviderService.test.ts | 33 ++++---- apps/server/src/provider/ProviderService.ts | 43 +++++++---- .../provider/ProviderSessionDirectory.test.ts | 4 +- .../src/provider/ProviderSessionDirectory.ts | 5 +- .../src/provider/ProviderSessionReaper.ts | 1 + .../Services/ProviderAdapterRegistry.ts | 3 - .../src/provider/Services/ProviderRegistry.ts | 2 - .../src/provider/Services/ProviderService.ts | 5 -- apps/server/src/provider/opencodeRuntime.ts | 13 +++- .../providerMaintenanceRunner.test.ts | 31 ++++++++ .../src/provider/providerMaintenanceRunner.ts | 76 +++++++++++++++---- docs/architecture/overview.md | 2 +- docs/operations/effect-fn-checklist.md | 34 ++++----- docs/reference/encyclopedia.md | 2 +- 30 files changed, 323 insertions(+), 205 deletions(-) delete mode 100644 apps/server/src/provider/Layers/ProviderEventLoggers.ts delete mode 100644 apps/server/src/provider/Layers/ProviderService.ts delete mode 100644 apps/server/src/provider/Layers/ProviderSessionDirectory.ts delete mode 100644 apps/server/src/provider/Services/ProviderAdapterRegistry.ts delete mode 100644 apps/server/src/provider/Services/ProviderRegistry.ts delete mode 100644 apps/server/src/provider/Services/ProviderService.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ebc4f984b86..50a9eeaa68c 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -33,17 +33,13 @@ import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; -import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; +import * as ProviderAdapterRegistry from "../src/provider/ProviderAdapterRegistry.ts"; import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts"; -import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import * as ProviderSessionDirectory from "../src/provider/ProviderSessionDirectory.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; -import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; +import * as ProviderService from "../src/provider/ProviderService.ts"; import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts"; -import { - NoOpProviderEventLoggers, - ProviderEventLoggers, -} from "../src/provider/Layers/ProviderEventLoggers.ts"; -import { ProviderService } from "../src/provider/Services/ProviderService.ts"; +import * as ProviderEventLoggers from "../src/provider/ProviderEventLoggers.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; @@ -178,7 +174,7 @@ export interface OrchestrationIntegrationHarness { readonly adapterHarness: TestProviderAdapterHarness | null; readonly engine: OrchestrationEngineShape; readonly snapshotQuery: ProjectionSnapshotQuery["Service"]; - readonly providerService: ProviderService["Service"]; + readonly providerService: ProviderService.ProviderService["Service"]; readonly checkpointStore: CheckpointStore.CheckpointStore["Service"]; readonly checkpointRepository: ProjectionCheckpointRepository["Service"]; readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"]; @@ -241,7 +237,7 @@ export const makeOrchestrationIntegrationHarness = ( }); const fakeRegistry = adapterHarness ? Layer.succeed( - ProviderAdapterRegistry, + ProviderAdapterRegistry.ProviderAdapterRegistry, makeAdapterRegistryMock({ [adapterHarness.provider]: adapterHarness.adapter }), ) : null; @@ -262,11 +258,11 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), ); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + const providerSessionDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); const realCodexRegistry = Layer.effect( - ProviderAdapterRegistry, + ProviderAdapterRegistry.ProviderAdapterRegistry, Effect.gen(function* () { const codexSettings = yield* decodeCodexSettings({}); const codexAdapter = yield* makeCodexAdapter(codexSettings); @@ -279,15 +275,18 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), ); - const providerEventLoggersLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers); + const providerEventLoggersLayer = Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ); const providerLayer = useRealCodex - ? makeProviderServiceLive().pipe( + ? ProviderService.layer.pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(realCodexRegistry), Layer.provide(AnalyticsService.layerTest), Layer.provide(providerEventLoggersLayer), ) - : makeProviderServiceLive().pipe( + : ProviderService.layer.pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(fakeRegistry!), Layer.provide(AnalyticsService.layerTest), @@ -395,7 +394,7 @@ export const makeOrchestrationIntegrationHarness = ( runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), ).pipe(Effect.orDie); const providerService = yield* tryRuntimePromise("load ProviderService service", () => - runtime.runPromise(Effect.service(ProviderService)), + runtime.runPromise(Effect.service(ProviderService.ProviderService)), ).pipe(Effect.orDie); const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () => runtime.runPromise(Effect.service(CheckpointStore.CheckpointStore)), diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 76522c12e1a..c9026ab95ab 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -70,9 +70,7 @@ const makeIntegrationFixture = Effect.gen(function* () { ), ).pipe(Layer.provide(SqlitePersistenceMemory)); - const layer = Layer.effect(ProviderService.ProviderService, ProviderService.make()).pipe( - Layer.provide(shared), - ); + const layer = ProviderService.layer.pipe(Layer.provide(shared)); return { cwd, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 707c87c43c9..68f7214e4a4 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -49,10 +49,7 @@ import { } from "../Services/OrchestrationEngine.ts"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; -import { - ProviderService, - type ProviderServiceShape, -} from "../../provider/Services/ProviderService.ts"; +import * as ProviderService from "../../provider/ProviderService.ts"; import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; @@ -102,7 +99,7 @@ function createProviderServiceHarness( }, ] satisfies ReadonlyArray) : Effect.succeed([] as ReadonlyArray); - const service: ProviderServiceShape = { + const service: ProviderService.ProviderService["Service"] = { startSession: () => unsupported(), sendTurn: () => unsupported(), interruptTurn: () => unsupported(), @@ -328,7 +325,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(RuntimeReceiptBusLive), - Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), + Layer.provideMerge(Layer.succeed(ProviderService.ProviderService, provider.service)), Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))), Layer.provideMerge( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 3ba244ddf2c..a2423e54ad0 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -25,7 +25,7 @@ import { resolveThreadWorkspaceCwd, } from "../../checkpointing/Utils.ts"; import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; -import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import * as ProviderService from "../../provider/ProviderService.ts"; import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -80,7 +80,7 @@ const make = Effect.gen(function* () { randomUUID.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const providerService = yield* ProviderService; + const providerService = yield* ProviderService.ProviderService; const checkpointStore = yield* CheckpointStore.CheckpointStore; const receiptBus = yield* RuntimeReceiptBus; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ce464565dc5..4ebfc57eca7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -37,10 +37,7 @@ import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; -import { - ProviderService, - type ProviderServiceShape, -} from "../../provider/Services/ProviderService.ts"; +import * as ProviderService from "../../provider/ProviderService.ts"; import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; @@ -222,8 +219,12 @@ describe("ProviderCommandReactor", () => { }), ); const interruptTurn = vi.fn((_: unknown) => Effect.void); - const respondToRequest = vi.fn(() => Effect.void); - const respondToUserInput = vi.fn(() => Effect.void); + const respondToRequest = vi.fn( + () => Effect.void, + ); + const respondToUserInput = vi.fn< + ProviderService.ProviderService["Service"]["respondToUserInput"] + >(() => Effect.void); const stopSession = vi.fn((input: unknown) => Effect.sync(() => { const threadId = @@ -294,13 +295,15 @@ describe("ProviderCommandReactor", () => { ]; const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; - const service: ProviderServiceShape = { - startSession: startSession as ProviderServiceShape["startSession"], - sendTurn: sendTurn as ProviderServiceShape["sendTurn"], - interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], - respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], - respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], - stopSession: stopSession as ProviderServiceShape["stopSession"], + const service: ProviderService.ProviderService["Service"] = { + startSession: startSession as ProviderService.ProviderService["Service"]["startSession"], + sendTurn: sendTurn as ProviderService.ProviderService["Service"]["sendTurn"], + interruptTurn: interruptTurn as ProviderService.ProviderService["Service"]["interruptTurn"], + respondToRequest: + respondToRequest as ProviderService.ProviderService["Service"]["respondToRequest"], + respondToUserInput: + respondToUserInput as ProviderService.ProviderService["Service"]["respondToUserInput"], + stopSession: stopSession as ProviderService.ProviderService["Service"]["stopSession"], listSessions: () => Effect.succeed(runtimeSessions), getCapabilities: (_provider) => Effect.succeed({ @@ -346,7 +349,7 @@ describe("ProviderCommandReactor", () => { const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), - Layer.provideMerge(Layer.succeed(ProviderService, service)), + Layer.provideMerge(Layer.succeed(ProviderService.ProviderService, service)), Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), Layer.provideMerge( Layer.mock(GitWorkflowService.GitWorkflowService)({ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb1..ec350d7e9e2 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -30,8 +30,8 @@ import { increment, orchestrationEventsProcessedTotal } from "../../observabilit import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; -import { ProviderService } from "../../provider/Services/ProviderService.ts"; -import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; +import * as ProviderService from "../../provider/ProviderService.ts"; +import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { @@ -190,8 +190,8 @@ const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const providerService = yield* ProviderService; - const providerRegistry = yield* ProviderRegistry; + const providerService = yield* ProviderService.ProviderService; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 001ba388949..a518a20f826 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -35,10 +35,7 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; -import { - ProviderService, - type ProviderServiceShape, -} from "../../provider/Services/ProviderService.ts"; +import * as ProviderService from "../../provider/ProviderService.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -97,7 +94,7 @@ function createProviderServiceHarness() { const runtimeSessions: ProviderSession[] = []; const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; - const service: ProviderServiceShape = { + const service: ProviderService.ProviderService["Service"] = { startSession: () => unsupported(), sendTurn: () => unsupported(), interruptTurn: () => unsupported(), @@ -237,7 +234,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(SqlitePersistenceMemory), - Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), + Layer.provideMerge(Layer.succeed(ProviderService.ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3e5978f4846..003e2840932 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -27,7 +27,7 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; -import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import * as ProviderService from "../../provider/ProviderService.ts"; import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { isGitRepository } from "../../git/Utils.ts"; @@ -631,7 +631,7 @@ const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const providerService = yield* ProviderService; + const providerService = yield* ProviderService.ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; const serverSettingsService = yield* ServerSettingsService; const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 7d8a24069a3..a5843922929 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; -import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import * as ProviderService from "../../provider/ProviderService.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { @@ -38,7 +38,7 @@ export const logCleanupCauseUnlessInterrupted = ({ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; - const providerService = yield* ProviderService; + const providerService = yield* ProviderService.ProviderService; const terminalManager = yield* TerminalManager.TerminalManager; const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index f754d4ab9b2..4098cdd8223 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -16,6 +16,31 @@ const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; +it("keeps OpenCode runtime causes separate from stable messages", () => { + const cause = new Error("sdk response included sensitive diagnostics"); + const error = new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "provider.list", + detail: cause.message, + cause, + }); + + assert.equal(error.cause, cause); + assert.equal(error.message, "OpenCode runtime operation provider.list failed."); + assert.doesNotMatch(error.message, /sensitive diagnostics/u); + + const processExit = new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: "OpenCode server exited before startup completed.", + exitCode: 1, + stdout: "startup output", + stderr: "startup failure", + }); + assert.equal(processExit.cause, undefined); + assert.equal(processExit.exitCode, 1); + assert.equal(processExit.stdout, "startup output"); + assert.equal(processExit.stderr, "startup failure"); +}); + /** * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag * are deleted. The snapshot-producing logic they wrapped now lives in the diff --git a/apps/server/src/provider/Layers/ProviderEventLoggers.ts b/apps/server/src/provider/Layers/ProviderEventLoggers.ts deleted file mode 100644 index 37bc35ff958..00000000000 --- a/apps/server/src/provider/Layers/ProviderEventLoggers.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Compatibility shim for the intentionally excluded orchestration harness. -import * as Canonical from "../ProviderEventLoggers.ts"; - -export const ProviderEventLoggersLive = Canonical.layer; -export const ProviderEventLoggers = Canonical.ProviderEventLoggers; -export const NoOpProviderEventLoggers = Canonical.NoOpProviderEventLoggers; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts deleted file mode 100644 index 93d3d246990..00000000000 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Compatibility shim for the intentionally excluded orchestration harness. -import * as Layer from "effect/Layer"; - -import * as ProviderService from "../ProviderService.ts"; - -export type ProviderServiceLiveOptions = ProviderService.ProviderServiceOptions; - -export const ProviderServiceLive = ProviderService.layer; - -export const makeProviderServiceLive = (options?: ProviderService.ProviderServiceOptions) => - Layer.effect(ProviderService.ProviderService, ProviderService.make(options)); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts deleted file mode 100644 index ffc06ee2b5f..00000000000 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Compatibility shim for the intentionally excluded orchestration harness. -import * as ProviderSessionDirectory from "../ProviderSessionDirectory.ts"; - -export const ProviderSessionDirectoryLive = ProviderSessionDirectory.layer; - -export const makeProviderSessionDirectoryLive = () => ProviderSessionDirectory.layer; diff --git a/apps/server/src/provider/ProviderAdapterRegistry.ts b/apps/server/src/provider/ProviderAdapterRegistry.ts index d627e25c0fe..b1653af368a 100644 --- a/apps/server/src/provider/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/ProviderAdapterRegistry.ts @@ -12,11 +12,16 @@ * - `getByInstance` / `listInstances` — new per-instance routing. Callers * that already know an `instanceId` (threads, sessions, events) * should prefer these. + * - `listProviders` — legacy kind-keyed routing for default instances * (`defaultInstanceIdForDriver(kind) === kind`), matching the pre-Slice-D * behaviour. New code should not grow additional callers of the kind-keyed * methods; they exist so the settings UI, WS refresh RPC, and a handful * of legacy persisted rows can still be routed during the rollout. * + * Adapter lookups are resolved dynamically through `ProviderInstanceRegistry`. + * Settings-driven hot reload is therefore visible immediately without + * rebuilding this facade. + * * @module ProviderAdapterRegistry */ import { @@ -50,7 +55,10 @@ export class ProviderAdapterRegistry extends Context.Service< { /** * Resolve the adapter for a specific instance id. Returns - * `ProviderUnsupportedError` if no such live instance is registered. + * `ProviderUnsupportedError` if no such instance is currently registered + * (which covers "never configured" and "configured but the driver is + * unavailable in this build"; both surface the same failure to callers + * that expect a working adapter). */ readonly getByInstance: ( instanceId: ProviderInstanceId, @@ -62,28 +70,33 @@ export class ProviderAdapterRegistry extends Context.Service< ) => Effect.Effect; /** - * List every live instance id. Unavailable shadow instances are excluded - * because callers use these ids with `getByInstance`. + * List all live instance ids. Excludes unavailable/shadow instances because + * callers of this method want something they can pass to `getByInstance`. */ readonly listInstances: () => Effect.Effect>; /** * List provider kinds whose default instance is currently registered. * - * @deprecated Prefer `listInstances`; this remains for migration-era - * callers that still address providers by driver kind. + * @deprecated Prefer `listInstances`. Retained for migration-era call sites + * that iterate providers to build UI or metrics. */ readonly listProviders: () => Effect.Effect>; /** - * Emits whenever the live instance set changes. Consumers should re-read - * `listInstances` and reconcile their per-instance subscriptions. + * Change notification stream mirroring + * `ProviderInstanceRegistry.streamChanges`. Emits one `void` tick whenever + * the set of live instances changes. Consumers that fan out + * `adapter.streamEvents` per instance re-pull `listInstances` on each tick + * and fork subscriptions for instances they have not seen yet. */ readonly streamChanges: Stream.Stream; /** - * Acquire the change subscription synchronously in the caller's scope, - * avoiding the publish race inherent in forking `Stream.fromPubSub`. + * Acquire a change subscription synchronously in the caller's current + * fiber. Consumers that must avoid missing a publish between initial + * reconciliation and watcher startup should use this, then fork + * `Stream.fromSubscription`. */ readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } @@ -130,6 +143,8 @@ export const make = Effect.gen(function* () { const kinds = new Set(); for (const instance of instances) { if (instance.instanceId === defaultInstanceIdForDriver(instance.driverKind)) { + // Only default instances appear through this legacy view; custom + // instances such as `codex_personal` have no driver-kind identity. kinds.add(instance.driverKind); } } @@ -142,6 +157,8 @@ export const make = Effect.gen(function* () { getInstanceInfo, listInstances, listProviders, + // The facade owns no state; the instance registry already coalesces + // add, remove, and rebuild notifications into a single change channel. streamChanges: registry.streamChanges, subscribeChanges: registry.subscribeChanges, }); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index cfd17b13b0e..0d203b9ed7d 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -58,38 +58,46 @@ export class ProviderInstanceRegistry extends Context.Service< ProviderInstanceRegistry, { /** - * Look up one instance by id. Unknown ids return `undefined`; callers map - * that absence to the appropriate domain error. + * Look up one instance by id. Returns `undefined` (not `Option`) when the + * id is unknown; callers branch on absence and emit the appropriate domain + * error. */ readonly getInstance: ( instanceId: ProviderInstanceId, ) => Effect.Effect; /** - * Every successfully materialized instance, in stable settings-author - * order. + * Every available (driver-registered, successfully created) instance, in + * stable settings-author order. */ readonly listInstances: Effect.Effect>; /** - * Shadow snapshots for unknown drivers or invalid configurations, ready - * to merge into `ProviderRegistry` output. + * Wire-shape shadow snapshots for instances whose driver is unknown to this + * build or whose config failed to decode. Suitable for merging directly + * into `ProviderRegistry` output. */ readonly listUnavailable: Effect.Effect>; /** - * Emits after the registry adds, removes, or rebuilds instances. The - * payload is `void` because consumers re-read both registry lists. + * Push notification stream emitted whenever the registry's contents change. + * The payload is `void` because consumers always re-pull `listInstances` + * and `listUnavailable` together. * - * `Stream.fromPubSub` subscribes only when execution begins, so a newly - * forked consumer can miss a publish. Consumers that cannot tolerate that - * gap should acquire `subscribeChanges` first. + * `Stream.fromPubSub` defers `PubSub.subscribe` until the stream starts + * running, so forking a consumer races the next publish. Hot-reload + * consumers that cannot miss a publish should use `subscribeChanges` + * instead, which acquires the subscription synchronously before the + * consumer loop is forked. */ readonly streamChanges: Stream.Stream; /** - * Acquire a scoped PubSub subscription synchronously before forking the - * consumer loop, ensuring subsequent publishes cannot land in a gap. + * Acquire a scoped subscription to the change channel synchronously in the + * caller's fiber. Consumers typically `yield*` this in the same fiber that + * forks their consumer loop, then drain it with `PubSub.take` or + * `Stream.fromSubscription`. Because the subscription is registered before + * this effect returns, no subsequent publish can land in a gap. */ readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } @@ -107,9 +115,14 @@ export class ProviderInstanceRegistryMutator extends Context.Service< ProviderInstanceRegistryMutator, { /** - * Reconcile the live registry with a new configuration map. Individual - * driver creation failures become unavailable shadow snapshots so the - * settings watcher itself never fails. + * Bring the live registry in line with the supplied config map. The + * operation closes removed or replaced scopes before creating replacements + * and publishes a single change tick after the batch. Reapplying an + * unchanged map is a no-op. + * + * The effect never fails: individual driver creation failures become + * unavailable shadow snapshots, keeping settings-watcher loops alive when + * one entry is invalid. */ readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect; } diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 593d856c9e7..a631e11a737 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -64,13 +64,18 @@ export class ProviderRegistry extends Context.Service< ProviderRegistry, { /** - * Read the latest snapshot for every configured instance. Multiple - * snapshots can share a driver kind and are distinguished by instance id. + * Read the latest provider snapshots for every configured instance. + * Multiple snapshots may share the same driver kind and disambiguate via + * `instanceId`. */ readonly getProviders: Effect.Effect>; /** - * Refresh every provider, or the default instance for one driver kind. + * Refresh all providers, or the default instance of the specified kind + * when supplied. + * + * Retained for compatibility with legacy call sites such as the WS refresh + * RPC and orchestration metrics. * * @deprecated Prefer `refreshInstance` for new call sites. */ @@ -80,15 +85,16 @@ export class ProviderRegistry extends Context.Service< /** * Refresh one configured instance. Unknown ids resolve to the current - * cached list to preserve the legacy transport behavior. + * cached list, matching the legacy `refresh` behavior so transport layers + * do not have to special-case unknown ids. */ readonly refreshInstance: ( instanceId: ProviderInstanceId, ) => Effect.Effect>; /** - * Resolve maintenance capabilities for a live instance, falling back to - * manual-only capabilities when that instance is unavailable. + * Resolve maintenance capabilities for one live provider instance, + * falling back to manual-only capabilities when it is unavailable. */ readonly getProviderMaintenanceCapabilitiesForInstance: ( instanceId: ProviderInstanceId, @@ -96,8 +102,10 @@ export class ProviderRegistry extends Context.Service< ) => Effect.Effect; /** - * Apply volatile maintenance state for one instance. This state is not - * persisted; update actions are projected onto `ServerProvider.updateState`. + * Apply volatile maintenance-action state to one configured instance. This + * state is never persisted. Today only update actions are projected onto + * `ServerProvider.updateState`; install/auth actions can extend this map + * without adding driver-scoped APIs. */ readonly setProviderMaintenanceActionState: (input: { readonly instanceId: ProviderInstanceId; @@ -105,7 +113,10 @@ export class ProviderRegistry extends Context.Service< readonly state: ServerProviderUpdateState | null; }) => Effect.Effect>; - /** Emits the full materialized provider list after each aggregated change. */ + /** + * Stream of provider snapshot updates, one emission per aggregated change. + * The array contains the full current state. + */ readonly streamChanges: Stream.Stream>; } >()("t3/provider/ProviderRegistry") {} @@ -671,19 +682,6 @@ export const make = Effect.gen(function* () { // and pending fallbacks fill the gaps. yield* upsertProviders(fallbackProviders, { publish: false }); // Subscribe to registry mutations BEFORE running the initial sync. - // `subscribeChanges` acquires the dequeue synchronously in this - // fibre; the subscription is active the instant this `yield*` - // returns. Forking the consumer loop later cannot lose a publish - // because no publish can reach a not-yet-subscribed dequeue. - // - // (Contrast with the pre-fix code that did - // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. - // `Stream.fromPubSub` defers `PubSub.subscribe` to stream start, - // and `forkScoped` only schedules the fibre — so a reconcile that - // published between "fibre scheduled" and "fibre starts running" - // was dropped, which made any settings change that replaced an - // instance never propagate to the aggregator's `providersRef`.) - // Subscribe to registry mutations BEFORE running the initial sync. // `subscribeChanges` acquires the `PubSub.Subscription` synchronously // in this fibre; the subscription is registered with the PubSub the // instant this `yield*` returns, so any subsequent publish is diff --git a/apps/server/src/provider/ProviderService.test.ts b/apps/server/src/provider/ProviderService.test.ts index 5bcc103b4f1..8c020222e07 100644 --- a/apps/server/src/provider/ProviderService.test.ts +++ b/apps/server/src/provider/ProviderService.test.ts @@ -55,10 +55,13 @@ import { } from "../persistence/Layers/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; +import { makeAdapterRegistryMock } from "./testUtils/providerAdapterRegistryMock.ts"; -const makeProviderServiceLive = (options?: ProviderService.ProviderServiceOptions) => +// Several tests build two ProviderService instances in one scope to simulate a +// process restart. Construct a fresh Layer for each build so Layer memoization +// cannot reuse the first service instance. +const makeFreshProviderServiceLayer = (options?: ProviderService.ProviderServiceOptions) => Layer.effect(ProviderService.ProviderService, ProviderService.make(options)); -import { makeAdapterRegistryMock } from "./testUtils/providerAdapterRegistryMock.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); @@ -289,7 +292,7 @@ function makeProviderServiceLayer() { const layer = it.layer( Layer.mergeAll( - makeProviderServiceLive().pipe( + makeFreshProviderServiceLayer().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -342,7 +345,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => Layer.provide(runtimeRepositoryLayer), ); const providerLayer = Layer.mergeAll( - makeProviderServiceLive().pipe( + makeFreshProviderServiceLayer().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -403,7 +406,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () const directoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const providerLayer = makeProviderServiceLive().pipe( + const providerLayer = makeFreshProviderServiceLayer().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -487,7 +490,7 @@ it.effect( const directoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const providerLayer = makeProviderServiceLive().pipe( + const providerLayer = makeFreshProviderServiceLayer().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), @@ -559,7 +562,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance const directoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const providerLayer = makeProviderServiceLive().pipe( + const providerLayer = makeFreshProviderServiceLayer().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -606,7 +609,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se const directoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const providerLayer = makeProviderServiceLive({ + const providerLayer = makeFreshProviderServiceLayer({ canonicalEventLogger: { filePath: "memory://provider-canonical-events", write: (event, threadId) => { @@ -678,7 +681,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }); }).pipe(Effect.provide(directoryLayer)); - const providerLayer = makeProviderServiceLive().pipe( + const providerLayer = makeFreshProviderServiceLayer().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -742,7 +745,7 @@ it.effect( const firstDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const firstProviderLayer = makeProviderServiceLive().pipe( + const firstProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -801,7 +804,7 @@ it.effect( const secondDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const secondProviderLayer = makeProviderServiceLive().pipe( + const secondProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), @@ -1312,7 +1315,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const firstDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const firstProviderLayer = makeProviderServiceLive().pipe( + const firstProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -1350,7 +1353,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const secondDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const secondProviderLayer = makeProviderServiceLive().pipe( + const secondProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), @@ -1418,7 +1421,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const firstDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const firstProviderLayer = makeProviderServiceLive().pipe( + const firstProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -1451,7 +1454,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const secondDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const secondProviderLayer = makeProviderServiceLive().pipe( + const secondProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), diff --git a/apps/server/src/provider/ProviderService.ts b/apps/server/src/provider/ProviderService.ts index 53fd2308b35..703b5773965 100644 --- a/apps/server/src/provider/ProviderService.ts +++ b/apps/server/src/provider/ProviderService.ts @@ -6,6 +6,8 @@ * unified provider event stream for subscribers. * * It does not implement provider protocol details (adapter concern). + * Uses Effect `Context.Service` for dependency injection and returns typed + * domain errors for validation, session, codex, and checkpoint workflows. * * @module ProviderService */ @@ -61,14 +63,22 @@ import * as ProviderSessionDirectory from "./ProviderSessionDirectory.ts"; import { type EventNdjsonLogger } from "./Layers/EventNdjsonLogger.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; -import * as McpProviderSession from "../mcp/McpProviderSession.ts"; -import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; +import { + clearAllMcpProviderSessions, + clearMcpProviderSession, + setMcpProviderSession, +} from "../mcp/McpProviderSession.ts"; +import { + issueActiveMcpCredential, + revokeActiveMcpThread, + revokeAllActiveMcpCredentials, +} from "../mcp/McpSessionRegistry.ts"; const isModelSelection = Schema.is(ModelSelection); export class ProviderService extends Context.Service< ProviderService, { - /** Start a provider session for a thread. */ + /** Start a provider session. */ readonly startSession: ( threadId: ThreadId, input: ProviderSessionStartInput, @@ -99,10 +109,14 @@ export class ProviderService extends Context.Service< input: ProviderStopSessionInput, ) => Effect.Effect; - /** Aggregate the active sessions reported by all registered adapters. */ + /** + * List active provider sessions. + * + * Aggregates runtime session lists from all registered adapters. + */ readonly listSessions: () => Effect.Effect>; - /** Read capabilities for the adapter bound to an instance. */ + /** Read capabilities for the adapter bound to a configured provider instance. */ readonly getCapabilities: ( instanceId: ProviderInstanceId, ) => Effect.Effect; @@ -119,8 +133,9 @@ export class ProviderService extends Context.Service< }) => Effect.Effect; /** - * Canonical provider runtime event stream. ProviderService owns the - * per-instance fan-out rather than delegating it to a separate event bus. + * Canonical provider runtime event stream. + * + * Fan-out is owned by ProviderService (not by a standalone event-bus service). */ readonly streamEvents: Stream.Stream; } @@ -279,16 +294,14 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi const runtimeEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => - McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( + issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( Effect.tap((credential) => - credential - ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)) - : Effect.void, + credential ? Effect.sync(() => setMcpProviderSession(credential.config)) : Effect.void, ), ); const clearMcpSession = (threadId: ThreadId) => - McpSessionRegistry.revokeActiveMcpThread(threadId).pipe( - Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))), + revokeActiveMcpThread(threadId).pipe( + Effect.tap(() => Effect.sync(() => clearMcpProviderSession(threadId))), ); const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => @@ -1096,8 +1109,8 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi ), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); - yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); - McpProviderSession.clearAllMcpProviderSessions(); + yield* revokeAllActiveMcpCredentials(); + clearAllMcpProviderSessions(); const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); yield* Effect.forEach(bindings, (binding) => Effect.gen(function* () { diff --git a/apps/server/src/provider/ProviderSessionDirectory.test.ts b/apps/server/src/provider/ProviderSessionDirectory.test.ts index 9def269b314..a51d0cf3406 100644 --- a/apps/server/src/provider/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/ProviderSessionDirectory.test.ts @@ -20,9 +20,7 @@ import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.t import * as ProviderSessionDirectory from "./ProviderSessionDirectory.ts"; function makeDirectoryLayer(persistenceLayer: Layer.Layer) { - const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( - Layer.provide(persistenceLayer), - ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); return Layer.mergeAll( runtimeRepositoryLayer, ProviderSessionDirectory.layer.pipe(Layer.provide(runtimeRepositoryLayer)), diff --git a/apps/server/src/provider/ProviderSessionDirectory.ts b/apps/server/src/provider/ProviderSessionDirectory.ts index 4045a5ab157..3161c8a4de7 100644 --- a/apps/server/src/provider/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/ProviderSessionDirectory.ts @@ -14,7 +14,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "./Errors.ts"; -import * as ProviderSessionRuntime from "../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; export interface ProviderRuntimeBinding { readonly threadId: ThreadId; @@ -119,6 +119,9 @@ function toRuntimeBinding( ({ threadId: runtime.threadId, provider, + // Migration boundary: rows written before provider instances had a + // nullable id. Promote them here so runtime routing never has to + // infer an instance from its driver kind. providerInstanceId: runtime.providerInstanceId ?? defaultInstanceIdForDriver(provider), adapterKey: runtime.adapterKey, runtimeMode: runtime.runtimeMode, diff --git a/apps/server/src/provider/ProviderSessionReaper.ts b/apps/server/src/provider/ProviderSessionReaper.ts index 1a4ea8fbbb3..1516a525ab4 100644 --- a/apps/server/src/provider/ProviderSessionReaper.ts +++ b/apps/server/src/provider/ProviderSessionReaper.ts @@ -14,6 +14,7 @@ import * as ProviderSessionDirectory from "./ProviderSessionDirectory.ts"; export class ProviderSessionReaper extends Context.Service< ProviderSessionReaper, { + /** Start the background provider session reaper within the provided scope. */ readonly start: () => Effect.Effect; } >()("t3/provider/ProviderSessionReaper") {} diff --git a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts deleted file mode 100644 index c3841eadf2e..00000000000 --- a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Compatibility shim for the intentionally excluded orchestration harness. -export { ProviderAdapterRegistry } from "../ProviderAdapterRegistry.ts"; -export type { ProviderInstanceRoutingInfo } from "../ProviderAdapterRegistry.ts"; diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts deleted file mode 100644 index 96c841a8ad0..00000000000 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Compatibility shim for the intentionally excluded orchestration modules. -export * from "../ProviderRegistry.ts"; diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts deleted file mode 100644 index 4477887cd24..00000000000 --- a/apps/server/src/provider/Services/ProviderService.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Compatibility shim for the intentionally excluded orchestration modules and harness. -export * from "../ProviderService.ts"; - -/** @deprecated Use `ProviderService["Service"]` from the canonical module. */ -export type ProviderServiceShape = import("../ProviderService.ts").ProviderService["Service"]; diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 06f29944be0..1b738a4c85b 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import * as ProcessRunner from "../processRunner.ts"; +import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -54,12 +54,15 @@ export class OpenCodeRuntimeError extends Schema.TaggedErrorClass Effect.fail(cause)), + ); +} + function makeRegistry( initialProviders: ServerProvider | ReadonlyArray = baseProvider, ) { @@ -320,6 +328,29 @@ describe("providerMaintenanceRunner", () => { }, ); + it.effect("preserves update spawn failure messages", () => { + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: "npm", + description: "", + }); + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const runner = yield* makeTestRunner(registry); + + const result = yield* runner.updateProvider(CODEX_DRIVER); + assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); + assert.strictEqual( + result.providers[0]?.updateState?.message, + `Failed to run update command npm: ${cause.message}`, + ); + }).pipe( + Effect.provide(Layer.mergeAll(latestVersionHttpClient("0.0.0"), failingSpawnerLayer(cause))), + ); + }); + it.effect("updates a single provider instance without touching sibling instances", () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index bfb744f0cd8..468009252a8 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; @@ -52,20 +53,68 @@ export class ProviderMaintenanceRunner extends Context.Service< } >()("t3/provider/providerMaintenanceRunner") {} -class ProviderMaintenanceCommandError extends Schema.TaggedErrorClass()( - "ProviderMaintenanceCommandError", +const platformFailureFields = { + failureTag: Schema.NullOr(Schema.String), + failureModule: Schema.String, + failureMethod: Schema.String, + failureDescription: Schema.NullOr(Schema.String), + failurePathOrDescriptor: Schema.NullOr(Schema.Union([Schema.String, Schema.Number])), +} as const; + +interface PlatformFailureAttributes { + readonly failureTag: string | null; + readonly failureModule: string; + readonly failureMethod: string; + readonly failureDescription: string | null; + readonly failurePathOrDescriptor: string | number | null; +} + +function platformFailureAttributes(cause: PlatformError.PlatformError): PlatformFailureAttributes { + const { reason } = cause; + return { + failureTag: reason._tag === "BadArgument" ? null : reason._tag, + failureModule: reason.module, + failureMethod: reason.method, + failureDescription: reason.description || null, + failurePathOrDescriptor: + "pathOrDescriptor" in reason ? (reason.pathOrDescriptor ?? null) : null, + }; +} + +function formatPlatformFailure(attributes: PlatformFailureAttributes): string { + const tag = attributes.failureTag === null ? "" : `${attributes.failureTag}: `; + const path = + attributes.failurePathOrDescriptor === null + ? "" + : ` (${String(attributes.failurePathOrDescriptor)})`; + const description = + attributes.failureDescription === null ? "" : `: ${attributes.failureDescription}`; + return `${tag}${attributes.failureModule}.${attributes.failureMethod}${path}${description}`; +} + +class ProviderMaintenanceCommandSpawnError extends Schema.TaggedErrorClass()( + "ProviderMaintenanceCommandSpawnError", { - operation: Schema.Literals(["spawn", "collect"]), - command: Schema.optional(Schema.String), + command: Schema.String, + ...platformFailureFields, cause: Schema.Defect(), }, ) { override get message(): string { - const causeMessage = this.cause instanceof Error ? this.cause.message : undefined; - if (this.operation === "spawn") { - return `Failed to run update command ${this.command ?? "unknown"}${causeMessage ? `: ${causeMessage}` : "."}`; - } - return causeMessage ?? "Update command failed to run."; + return `Failed to run update command ${this.command}: ${formatPlatformFailure(this)}`; + } +} + +class ProviderMaintenanceCommandCollectError extends Schema.TaggedErrorClass()( + "ProviderMaintenanceCommandCollectError", + { + command: Schema.String, + ...platformFailureFields, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return formatPlatformFailure(this); } } @@ -89,9 +138,9 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR .pipe( Effect.mapError( (cause) => - new ProviderMaintenanceCommandError({ - operation: "spawn", + new ProviderMaintenanceCommandSpawnError({ command: input.command, + ...platformFailureAttributes(cause), cause, }), ), @@ -114,8 +163,9 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR ).pipe( Effect.mapError( (cause) => - new ProviderMaintenanceCommandError({ - operation: "collect", + new ProviderMaintenanceCommandCollectError({ + command: input.command, + ...platformFailureAttributes(cause), cause, }), ), diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index ce5a0afe92a..607f061bf88 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -132,7 +132,7 @@ sequenceDiagram [5]: ../apps/server/src/wsServer/pushBus.ts [6]: ../packages/contracts/src/ws.ts [7]: ../apps/server/src/serverLayers.ts -[8]: ../apps/server/src/provider/Layers/ProviderService.ts +[8]: ../apps/server/src/provider/ProviderService.ts [9]: ../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts [10]: ../apps/server/src/orchestration/Layers/OrchestrationEngine.ts [11]: ../packages/contracts/src/orchestration.ts diff --git a/docs/operations/effect-fn-checklist.md b/docs/operations/effect-fn-checklist.md index 279b5646d32..add2ca96879 100644 --- a/docs/operations/effect-fn-checklist.md +++ b/docs/operations/effect-fn-checklist.md @@ -41,7 +41,7 @@ Effect.fn("name")( ## Suggested Order -- [ ] `apps/server/src/provider/Layers/ProviderService.ts` +- [ ] `apps/server/src/provider/ProviderService.ts` - [x] `apps/server/src/provider/Layers/ClaudeAdapter.ts` - [x] `apps/server/src/provider/Layers/CodexAdapter.ts` - [x] `apps/server/src/git/Layers/GitCore.ts` @@ -98,20 +98,20 @@ Effect.fn("name")( - [x] `Effect.forEach(..., entry => Effect.gen(...))` callbacks around `L305` - [x] Remaining apply helpers in this file -### `apps/server/src/provider/Layers/ProviderService.ts` (`24`) - -- [ ] [makeProviderService](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L134) -- [ ] [recoverSessionForThread](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L196) -- [ ] [resolveRoutableSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L255) -- [ ] [startSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L284) -- [ ] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L347) -- [ ] [interruptTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L393) -- [ ] [respondToRequest](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L411) -- [ ] [respondToUserInput](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L430) -- [ ] [stopSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L445) -- [ ] [listSessions](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L466) -- [ ] [rollbackConversation](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L516) -- [ ] [runStopAll](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L538) +### `apps/server/src/provider/ProviderService.ts` (`24`) + +- [ ] [makeProviderService](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L134) +- [ ] [recoverSessionForThread](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L196) +- [ ] [resolveRoutableSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L255) +- [ ] [startSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L284) +- [ ] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L347) +- [ ] [interruptTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L393) +- [ ] [respondToRequest](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L411) +- [ ] [respondToUserInput](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L430) +- [ ] [stopSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L445) +- [ ] [listSessions](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L466) +- [ ] [rollbackConversation](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L516) +- [ ] [runStopAll](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderService.ts#L538) ### `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` (`14`) @@ -174,7 +174,7 @@ Effect.fn("name")( - [ ] [packages/shared/src/DrainableWorker.ts](/Users/julius/Development/Work/codething-mvp/packages/shared/src/DrainableWorker.ts) (`4`) - [ ] [apps/server/src/wsServer/pushBus.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/wsServer/pushBus.ts) (`4`) - [ ] [apps/server/src/wsServer.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/wsServer.ts) (`4`) -- [ ] [apps/server/src/provider/Layers/ProviderRegistry.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderRegistry.ts) (`4`) +- [ ] [apps/server/src/provider/ProviderRegistry.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderRegistry.ts) (`4`) - [ ] [apps/server/src/persistence/Layers/Sqlite.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/persistence/Layers/Sqlite.ts) (`4`) - [ ] [apps/server/src/orchestration/Layers/ProviderCommandReactor.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts) (`4`) - [ ] [apps/server/src/main.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/main.ts) (`4`) @@ -183,7 +183,7 @@ Effect.fn("name")( - [ ] [apps/server/src/serverLayers.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/serverLayers.ts) (`3`) - [ ] [apps/server/src/telemetry/Layers/AnalyticsService.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/telemetry/Layers/AnalyticsService.ts) (`2`) - [ ] [apps/server/src/telemetry/Identify.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/telemetry/Identify.ts) (`2`) -- [ ] [apps/server/src/provider/Layers/ProviderAdapterRegistry.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts) (`2`) +- [ ] [apps/server/src/provider/ProviderAdapterRegistry.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/ProviderAdapterRegistry.ts) (`2`) - [ ] [apps/server/src/provider/Layers/CodexProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexProvider.ts) (`2`) - [ ] [apps/server/src/provider/Layers/ClaudeProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeProvider.ts) (`2`) - [ ] [apps/server/src/persistence/NodeSqliteClient.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/persistence/NodeSqliteClient.ts) (`2`) diff --git a/docs/reference/encyclopedia.md b/docs/reference/encyclopedia.md index 82a58fd959c..e7f53c414b7 100644 --- a/docs/reference/encyclopedia.md +++ b/docs/reference/encyclopedia.md @@ -167,7 +167,7 @@ The file patch and changed-file summary for one turn. It is usually computed in [11]: ../apps/server/src/orchestration/Layers/ProjectionPipeline.ts [12]: ../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts [13]: ../apps/server/src/orchestration/Services/RuntimeReceiptBus.ts -[14]: ../apps/server/src/provider/Layers/ProviderService.ts +[14]: ../apps/server/src/provider/ProviderService.ts [15]: ../apps/server/src/provider/Services/ProviderAdapter.ts [16]: ./provider-architecture.md [17]: ../apps/server/src/provider/Layers/CodexAdapter.ts From 3c86d9021750b0a57f0c71a55024b6e7ec0c45b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:37:21 -0700 Subject: [PATCH 03/20] Use direct Effect service namespace imports Co-authored-by: codex --- apps/server/src/provider/Drivers/ClaudeDriver.ts | 4 ++-- apps/server/src/provider/Drivers/CodexDriver.ts | 4 ++-- apps/server/src/provider/Drivers/CursorDriver.ts | 4 ++-- apps/server/src/provider/Drivers/GrokDriver.ts | 4 ++-- apps/server/src/provider/Drivers/OpenCodeDriver.ts | 4 ++-- apps/server/src/provider/ProviderInstanceRegistry.test.ts | 3 ++- apps/server/src/provider/ProviderRegistry.test.ts | 5 +++-- apps/server/src/provider/opencodeRuntime.ts | 3 ++- apps/server/src/provider/providerMaintenanceRunner.test.ts | 5 +++-- apps/server/src/provider/providerMaintenanceRunner.ts | 5 +++-- 10 files changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 349f4f9c1d8..037176b4810 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -20,8 +20,8 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import * as ServerConfig from "../../config.ts"; diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ff0df268b73..4b3bdcb9dc4 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -28,8 +28,8 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import * as ServerConfig from "../../config.ts"; diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 023d18cdf7a..9455c0ef2db 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -18,8 +18,8 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 18b706b600e..baf38143aa1 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -5,8 +5,8 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index cb7626cf82b..c6d26c48365 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -19,8 +19,8 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import * as ServerConfig from "../../config.ts"; diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 46597699df7..f327277a4a3 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -36,7 +36,8 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as Config from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 790be3e37ef..df2108e8b1c 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -24,8 +24,9 @@ import { type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; import * as PlatformError from "effect/PlatformError"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { deepMerge } from "@t3tools/shared/Struct"; import { createModelCapabilities } from "@t3tools/shared/model"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 1b738a4c85b..718347634ac 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -24,7 +24,8 @@ import * as Result from "effect/Result"; import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index f333da21ec9..c21fe3a43c5 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -16,8 +16,9 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProviderRegistry from "./ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 468009252a8..8347ffa04bf 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -17,8 +17,9 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProviderRegistry from "./ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; From 48ad002806e1e3cdfbd90248448b831f2a47150f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:48:34 -0700 Subject: [PATCH 04/20] Tighten provider refactor tests and predicates Co-authored-by: codex --- .../src/provider/Layers/OpenCodeAdapter.ts | 3 +- .../provider/Layers/OpenCodeProvider.test.ts | 25 --------------- apps/server/src/provider/opencodeRuntime.ts | 8 ++--- .../providerMaintenanceRunner.test.ts | 31 ------------------- 4 files changed, 6 insertions(+), 61 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 1eb6e47bc19..af247dc20a3 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -38,6 +38,7 @@ import { import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { buildOpenCodePermissionRules, + isOpenCodeRuntimeError, OpenCodeRuntime, OpenCodeRuntimeError, openCodeQuestionId, @@ -131,7 +132,7 @@ const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProc new ProviderAdapterProcessError({ provider: PROVIDER, threadId, - detail: OpenCodeRuntimeError.is(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause), + detail: isOpenCodeRuntimeError(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause), cause, }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 4098cdd8223..f754d4ab9b2 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -16,31 +16,6 @@ const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; -it("keeps OpenCode runtime causes separate from stable messages", () => { - const cause = new Error("sdk response included sensitive diagnostics"); - const error = new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "provider.list", - detail: cause.message, - cause, - }); - - assert.equal(error.cause, cause); - assert.equal(error.message, "OpenCode runtime operation provider.list failed."); - assert.doesNotMatch(error.message, /sensitive diagnostics/u); - - const processExit = new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: "OpenCode server exited before startup completed.", - exitCode: 1, - stdout: "startup output", - stderr: "startup failure", - }); - assert.equal(processExit.cause, undefined); - assert.equal(processExit.exitCode, 1); - assert.equal(processExit.stdout, "startup output"); - assert.equal(processExit.stderr, "startup failure"); -}); - /** * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag * are deleted. The snapshot-producing logic they wrapped now lives in the diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 718347634ac..35422336e78 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -60,20 +60,20 @@ export class OpenCodeRuntimeError extends Schema.TaggedErrorClass 0) return cause.message.trim(); if (cause && typeof cause === "object") { // SDK v2 throws { response, request, error? } shapes — extract what's useful @@ -280,7 +280,7 @@ function ensureRuntimeError( detail: string, cause: unknown, ): OpenCodeRuntimeError { - return OpenCodeRuntimeError.is(cause) + return isOpenCodeRuntimeError(cause) ? cause : new OpenCodeRuntimeError({ operation, detail, cause }); } diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index c21fe3a43c5..2322318359b 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -11,7 +11,6 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; @@ -142,13 +141,6 @@ function mockSpawnerLayer( ); } -function failingSpawnerLayer(cause: PlatformError.PlatformError) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.fail(cause)), - ); -} - function makeRegistry( initialProviders: ServerProvider | ReadonlyArray = baseProvider, ) { @@ -329,29 +321,6 @@ describe("providerMaintenanceRunner", () => { }, ); - it.effect("preserves update spawn failure messages", () => { - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcess", - method: "spawn", - pathOrDescriptor: "npm", - description: "", - }); - return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); - const runner = yield* makeTestRunner(registry); - - const result = yield* runner.updateProvider(CODEX_DRIVER); - assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); - assert.strictEqual( - result.providers[0]?.updateState?.message, - `Failed to run update command npm: ${cause.message}`, - ); - }).pipe( - Effect.provide(Layer.mergeAll(latestVersionHttpClient("0.0.0"), failingSpawnerLayer(cause))), - ); - }); - it.effect("updates a single provider instance without touching sibling instances", () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; return Effect.gen(function* () { From f8eb67ddd755173fbb23de66b283cf5c000b5c8e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:50:45 -0700 Subject: [PATCH 05/20] Namespace OpenCode runtime imports Co-authored-by: codex --- .../src/provider/Layers/OpenCodeAdapter.ts | 83 ++++++++----------- .../src/provider/Layers/OpenCodeProvider.ts | 26 +++--- 2 files changed, 52 insertions(+), 57 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index af247dc20a3..ff8be6da31b 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -25,7 +25,7 @@ import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerConfig } from "../../config.ts"; +import * as ServerConfig from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { @@ -36,20 +36,7 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { - buildOpenCodePermissionRules, - isOpenCodeRuntimeError, - OpenCodeRuntime, - OpenCodeRuntimeError, - openCodeQuestionId, - openCodeRuntimeErrorDetail, - parseOpenCodeModelSlug, - runOpenCodeSdk, - toOpenCodeFileParts, - toOpenCodePermissionReply, - toOpenCodeQuestionAnswers, - type OpenCodeServerConnection, -} from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as Option from "effect/Option"; const PROVIDER = ProviderDriverKind.make("opencode"); @@ -69,7 +56,7 @@ type OpenCodeSubscribedEvent = interface OpenCodeSessionContext { session: ProviderSession; readonly client: OpencodeClient; - readonly server: OpenCodeServerConnection; + readonly server: OpenCodeRuntime.OpenCodeServerConnection; readonly directory: string; readonly openCodeSessionId: string; readonly pendingPermissions: Map; @@ -109,12 +96,12 @@ export interface OpenCodeAdapterLiveOptions { const nowIso = Effect.map(DateTime.now, DateTime.formatIso); /** - * Map a tagged OpenCodeRuntimeError produced by {@link runOpenCodeSdk} into + * Map a tagged OpenCodeRuntimeError produced by {@link OpenCodeRuntime.runOpenCodeSdk} into * the adapter-boundary `ProviderAdapterRequestError`. SDK-method-level call * sites pipe through this in `Effect.mapError` so they never build the error * shape by hand. */ -const toRequestError = (cause: OpenCodeRuntimeError): ProviderAdapterRequestError => +const toRequestError = (cause: OpenCodeRuntime.OpenCodeRuntimeError): ProviderAdapterRequestError => new ProviderAdapterRequestError({ provider: PROVIDER, method: cause.operation, @@ -124,15 +111,17 @@ const toRequestError = (cause: OpenCodeRuntimeError): ProviderAdapterRequestErro /** * Map a `Cause.squash`-ed failure into a `ProviderAdapterProcessError`. The - * typed cause is usually an `OpenCodeRuntimeError` (from {@link runOpenCodeSdk}), + * typed cause is usually an `OpenCodeRuntimeError` (from {@link OpenCodeRuntime.runOpenCodeSdk}), * in which case we preserve its `detail`; otherwise we fall back to - * {@link openCodeRuntimeErrorDetail} for unknown causes (defects, etc.). + * {@link OpenCodeRuntime.openCodeRuntimeErrorDetail} for unknown causes (defects, etc.). */ const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProcessError => new ProviderAdapterProcessError({ provider: PROVIDER, threadId, - detail: isOpenCodeRuntimeError(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.isOpenCodeRuntimeError(cause) + ? cause.detail + : OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), cause, }); @@ -254,7 +243,7 @@ function ensureSessionContext( function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray { return request.questions.map((question, index) => ({ - id: openCodeQuestionId(index, question), + id: OpenCodeRuntime.openCodeQuestionId(index, question), header: question.header, question: question.question, options: question.options.map((option) => ({ @@ -414,7 +403,7 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( // Best-effort remote abort. The scope close below tears down the local // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), // but we still want to tell OpenCode that this session is done. - yield* runOpenCodeSdk("session.abort", () => + yield* OpenCodeRuntime.runOpenCodeSdk("session.abort", () => context.client.session.abort({ sessionID: context.openCodeSessionId }), ).pipe(Effect.ignore({ log: true })); @@ -431,8 +420,8 @@ export function makeOpenCodeAdapter( ) { return Effect.gen(function* () { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); - const serverConfig = yield* ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime; + const serverConfig = yield* ServerConfig.ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const crypto = yield* Crypto.Crypto; const nativeEventLogger = options?.nativeEventLogger ?? @@ -570,7 +559,7 @@ export function makeOpenCodeAdapter( // Inline the teardown that `stopOpenCodeContext` would do; we can't // delegate to it because our `getAndSet` above already flipped the // one-shot guard, so the call would no-op. - yield* runOpenCodeSdk("session.abort", () => + yield* OpenCodeRuntime.runOpenCodeSdk("session.abort", () => context.client.session.abort({ sessionID: context.openCodeSessionId }), ).pipe(Effect.ignore({ log: true })); yield* Scope.close(context.sessionScope, Exit.void); @@ -842,7 +831,7 @@ export function makeOpenCodeAdapter( context.pendingQuestions.delete(event.properties.requestID); const answers = Object.fromEntries( (request?.questions ?? []).map((question, index) => [ - openCodeQuestionId(index, question), + OpenCodeRuntime.openCodeQuestionId(index, question), event.properties.answers[index]?.join(", ") ?? "", ]), ); @@ -976,7 +965,7 @@ export function makeOpenCodeAdapter( // Fibers forked into `context.sessionScope` are interrupted // automatically when the scope closes — no bookkeeping required. yield* Effect.flatMap( - runOpenCodeSdk("event.subscribe", () => + OpenCodeRuntime.runOpenCodeSdk("event.subscribe", () => context.client.event.subscribe(undefined, { signal: eventsAbortController.signal, }), @@ -985,9 +974,9 @@ export function makeOpenCodeAdapter( Stream.fromAsyncIterable( subscription.stream, (cause) => - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "event.subscribe", - detail: openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), cause, }), ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), @@ -1003,7 +992,7 @@ export function makeOpenCodeAdapter( if (Exit.isFailure(exit)) { yield* emitUnexpectedExit( context, - openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), + OpenCodeRuntime.openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), ); } }), @@ -1057,7 +1046,7 @@ export function makeOpenCodeAdapter( }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); if (mcpSession && !server.external) { - yield* runOpenCodeSdk("mcp.add", () => + yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () => client.mcp.add({ name: "t3-code", config: { @@ -1071,14 +1060,14 @@ export function makeOpenCodeAdapter( }), ); } - const openCodeSession = yield* runOpenCodeSdk("session.create", () => + const openCodeSession = yield* OpenCodeRuntime.runOpenCodeSdk("session.create", () => client.session.create({ title: `T3 Code ${input.threadId}`, - permission: buildOpenCodePermissionRules(input.runtimeMode), + permission: OpenCodeRuntime.buildOpenCodePermissionRules(input.runtimeMode), }), ); if (!openCodeSession.data) { - return yield* new OpenCodeRuntimeError({ + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "session.create", detail: "OpenCode session.create returned no session payload.", }); @@ -1104,7 +1093,7 @@ export function makeOpenCodeAdapter( if (raceWinner) { // Another call won the race – clean up the session we just created // (including the remote SDK session) and return the existing one. - yield* runOpenCodeSdk("session.abort", () => + yield* OpenCodeRuntime.runOpenCodeSdk("session.abort", () => started.client.session.abort({ sessionID: started.openCodeSession.id, }), @@ -1186,7 +1175,7 @@ export function makeOpenCodeAdapter( issue: `OpenCode model selection is bound to instance '${modelSelection?.instanceId}', expected '${boundInstanceId}'.`, }); } - const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); + const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(modelSelection?.model); if (!parsedModel) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, @@ -1196,7 +1185,7 @@ export function makeOpenCodeAdapter( } const text = input.input?.trim(); - const fileParts = toOpenCodeFileParts({ + const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ attachments: input.attachments, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ @@ -1239,7 +1228,7 @@ export function makeOpenCodeAdapter( }); } - yield* runOpenCodeSdk("session.promptAsync", () => + yield* OpenCodeRuntime.runOpenCodeSdk("session.promptAsync", () => context.client.session.promptAsync({ sessionID: context.openCodeSessionId, model: parsedModel, @@ -1293,7 +1282,7 @@ export function makeOpenCodeAdapter( const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = ensureSessionContext(sessions, threadId); - yield* runOpenCodeSdk("session.abort", () => + yield* OpenCodeRuntime.runOpenCodeSdk("session.abort", () => context.client.session.abort({ sessionID: context.openCodeSessionId }), ).pipe(Effect.mapError(toRequestError)); if (turnId ?? context.activeTurnId) { @@ -1323,10 +1312,10 @@ export function makeOpenCodeAdapter( }); } - yield* runOpenCodeSdk("permission.reply", () => + yield* OpenCodeRuntime.runOpenCodeSdk("permission.reply", () => context.client.permission.reply({ requestID: requestId, - reply: toOpenCodePermissionReply(decision), + reply: OpenCodeRuntime.toOpenCodePermissionReply(decision), }), ).pipe(Effect.mapError(toRequestError)); }); @@ -1344,10 +1333,10 @@ export function makeOpenCodeAdapter( }); } - yield* runOpenCodeSdk("question.reply", () => + yield* OpenCodeRuntime.runOpenCodeSdk("question.reply", () => context.client.question.reply({ requestID: requestId, - answers: toOpenCodeQuestionAnswers(request, answers), + answers: OpenCodeRuntime.toOpenCodeQuestionAnswers(request, answers), }), ).pipe(Effect.mapError(toRequestError)); }); @@ -1387,7 +1376,7 @@ export function makeOpenCodeAdapter( const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( function* (threadId) { const context = ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => + const messages = yield* OpenCodeRuntime.runOpenCodeSdk("session.messages", () => context.client.session.messages({ sessionID: context.openCodeSessionId, }), @@ -1413,7 +1402,7 @@ export function makeOpenCodeAdapter( const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( function* (threadId, numTurns) { const context = ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => + const messages = yield* OpenCodeRuntime.runOpenCodeSdk("session.messages", () => context.client.session.messages({ sessionID: context.openCodeSessionId, }), @@ -1424,7 +1413,7 @@ export function makeOpenCodeAdapter( ); const targetIndex = assistantMessages.length - numTurns - 1; const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; - yield* runOpenCodeSdk("session.revert", () => + yield* OpenCodeRuntime.runOpenCodeSdk("session.revert", () => context.client.session.revert({ sessionID: context.openCodeSessionId, ...(target ? { messageID: target.info.id } : {}), diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index a8285e960fc..f1ab3b2d576 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -18,11 +18,7 @@ import { providerModelsFromSettings, type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { - OpenCodeRuntime, - openCodeRuntimeErrorDetail, - type OpenCodeInventory, -} from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; const PROVIDER = ProviderDriverKind.make("opencode"); @@ -219,7 +215,9 @@ function openCodeCapabilitiesForModel(input: { }); } -function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { +function flattenOpenCodeModels( + input: OpenCodeRuntime.OpenCodeInventory, +): ReadonlyArray { const connected = new Set(input.providerList.connected); const models: Array = []; @@ -302,8 +300,8 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu openCodeSettings: OpenCodeSettings, cwd: string, environment?: NodeJS.ProcessEnv, -): Effect.fn.Return { - const openCodeRuntime = yield* OpenCodeRuntime; +): Effect.fn.Return { + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; @@ -369,7 +367,11 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu }) .pipe( Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + (cause) => + new OpenCodeProbeError({ + cause, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + }), ), ), ); @@ -427,7 +429,11 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu ); }).pipe( Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + (cause) => + new OpenCodeProbeError({ + cause, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + }), ), ), ), From 7f05bcdbc0de00031e9d77a28762717573afca04 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:44:26 -0700 Subject: [PATCH 06/20] fix: preserve provider failure structure Co-authored-by: codex --- .../src/provider/Drivers/ClaudeDriver.ts | 2 +- .../src/provider/Drivers/CodexDriver.ts | 4 +- .../src/provider/Drivers/CursorDriver.ts | 2 +- .../server/src/provider/Drivers/GrokDriver.ts | 2 +- .../src/provider/Drivers/OpenCodeDriver.ts | 2 +- .../src/provider/Layers/CursorAdapter.ts | 4 +- .../server/src/provider/Layers/GrokAdapter.ts | 4 +- .../src/provider/Layers/OpenCodeProvider.ts | 32 ++-- .../src/provider/ProviderInstanceRegistry.ts | 9 +- apps/server/src/provider/ProviderRegistry.ts | 41 ++-- .../src/provider/ProviderService.test.ts | 13 ++ apps/server/src/provider/ProviderService.ts | 181 ++++++++++-------- .../provider/ProviderSessionDirectory.test.ts | 31 +++ .../src/provider/ProviderSessionDirectory.ts | 60 ++++-- .../src/provider/providerMaintenanceRunner.ts | 2 +- 15 files changed, 258 insertions(+), 131 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 037176b4810..2fec86194c2 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -193,7 +193,7 @@ export const ClaudeDriver: ProviderDriver = { new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to build Claude snapshot: ${cause.message ?? String(cause)}`, + detail: "Failed to build the Claude provider snapshot.", cause, }), ), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 4b3bdcb9dc4..6faaeebb162 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -134,7 +134,7 @@ export const CodexDriver: ProviderDriver = { new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: cause.message, + detail: "Failed to materialize the Codex shadow home.", cause, }), ), @@ -193,7 +193,7 @@ export const CodexDriver: ProviderDriver = { new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to build Codex snapshot: ${cause.message ?? String(cause)}`, + detail: "Failed to build the Codex provider snapshot.", cause, }), ), diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 9455c0ef2db..eb055e575da 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -165,7 +165,7 @@ export const CursorDriver: ProviderDriver = { new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`, + detail: "Failed to build the Cursor provider snapshot.", cause, }), ), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index baf38143aa1..5c249d9e296 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -141,7 +141,7 @@ export const GrokDriver: ProviderDriver = { new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to build Grok snapshot: ${cause.message ?? String(cause)}`, + detail: "Failed to build the Grok provider snapshot.", cause, }), ), diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index c6d26c48365..7e6516c520e 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -177,7 +177,7 @@ export const OpenCodeDriver: ProviderDriver new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`, + detail: "Failed to build the OpenCode provider snapshot.", cause, }), ), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index c0714873762..991dd5497a9 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -564,7 +564,7 @@ export function makeCursorAdapter( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, - detail: cause.message, + detail: "Failed to start the Cursor ACP session process.", cause, }), ), @@ -979,7 +979,7 @@ export function makeCursorAdapter( new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/prompt", - detail: cause.message, + detail: `Failed to read attachment '${attachment.id}'.`, cause, }), ), diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index c26e094aadd..13f275238d2 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -408,7 +408,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, - detail: cause.message, + detail: "Failed to start the Grok ACP session process.", cause, }), ), @@ -736,7 +736,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/prompt", - detail: cause.message, + detail: `Failed to read attachment '${attachment.id}'.`, cause, }), ), diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index f1ab3b2d576..acbe7e495ad 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -307,13 +307,11 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const customModels = openCodeSettings.customModels; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; - const fallback = (cause: unknown, version: string | null = null) => { - const failure = formatOpenCodeProbeError({ - cause, - isExternalServer, - serverUrl: openCodeSettings.serverUrl, - }); - return buildServerProvider({ + const buildFailureSnapshot = ( + failure: { readonly installed: boolean; readonly message: string }, + version: string | null, + ) => + buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, @@ -331,7 +329,16 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu message: failure.message, }, }); - }; + + const fallback = (cause: unknown, version: string | null = null) => + buildFailureSnapshot( + formatOpenCodeProbeError({ + cause, + isExternalServer, + serverUrl: openCodeSettings.serverUrl, + }), + version, + ); if (!openCodeSettings.enabled) { return buildServerProvider({ @@ -381,10 +388,11 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu version = parseGenericCliVersion(versionExit.value.stdout) ?? null; if (!version) { - return fallback( - new Error( - `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, - ), + return buildFailureSnapshot( + { + installed: true, + message: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + }, null, ); } diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 0d203b9ed7d..5a5dadb9868 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -48,6 +48,7 @@ import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -159,6 +160,8 @@ interface RegistryState { const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => Equal.equals(a, b); +const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); + const decodedConfigEnabled = (config: unknown): boolean | undefined => { if (!config || typeof config !== "object" || globalThis.Array.isArray(config)) { return undefined; @@ -203,12 +206,13 @@ const buildEntry = (input: { const decoder = Schema.decodeUnknownEffect(driver.configSchema); const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result); if (decodeResult._tag === "Failure") { - const issue = decodeResult.failure; - const detail = issue.message ?? String(issue); + const cause = decodeResult.failure; + const detail = formatSchemaIssue(cause.issue); yield* Effect.logError("Failed to decode provider instance config", { instanceId: rawInstanceId, driver: entry.driver, detail, + cause, }); return { kind: "unavailable" as const, @@ -246,6 +250,7 @@ const buildEntry = (input: { instanceId: rawInstanceId, driver: entry.driver, detail: createResult.failure.detail, + cause: createResult.failure, }); yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); return { diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index a631e11a737..009f0008944 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -25,7 +25,7 @@ import { defaultInstanceIdForDriver, ProviderDriverKind, - type ProviderInstanceId, + ProviderInstanceId, type ServerProvider, type ServerProviderUpdateState, } from "@t3tools/contracts"; @@ -38,8 +38,9 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; -import * as Stream from "effect/Stream"; +import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; import * as Config from "../config.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; @@ -60,6 +61,20 @@ import type { ProviderSnapshotSource } from "./builtInProviderCatalog.ts"; export type ProviderMaintenanceActionKind = "update"; +export class ProviderSnapshotCorrelationError extends Schema.TaggedErrorClass()( + "ProviderSnapshotCorrelationError", + { + sourceInstanceId: ProviderInstanceId, + snapshotInstanceId: ProviderInstanceId, + sourceDriver: ProviderDriverKind, + snapshotDriver: ProviderDriverKind, + }, +) { + override get message(): string { + return `Provider snapshot correlation failed for source instance '${this.sourceInstanceId}' (${this.sourceDriver}) and emitted instance '${this.snapshotInstanceId}' (${this.snapshotDriver}).`; + } +} + export class ProviderRegistry extends Context.Service< ProviderRegistry, { @@ -211,18 +226,14 @@ const correlateSnapshotWithSource = ( source: ProviderSnapshotSource, snapshot: ServerProvider, ): Effect.Effect => { - if (snapshot.instanceId !== source.instanceId) { + if (snapshot.instanceId !== source.instanceId || snapshot.driver !== source.driverKind) { return Effect.die( - new Error( - `Provider snapshot instance mismatch: source '${source.instanceId}' emitted '${snapshot.instanceId}'.`, - ), - ); - } - if (snapshot.driver !== source.driverKind) { - return Effect.die( - new Error( - `Provider snapshot driver mismatch for instance '${source.instanceId}': source '${source.driverKind}' emitted '${snapshot.driver}'.`, - ), + new ProviderSnapshotCorrelationError({ + sourceInstanceId: source.instanceId, + snapshotInstanceId: snapshot.instanceId, + sourceDriver: source.driverKind, + snapshotDriver: snapshot.driver, + }), ); } return Effect.succeed(snapshot); @@ -669,7 +680,7 @@ export const make = Effect.gen(function* () { return Effect.interrupt; } return Effect.logError("provider registry instance sync failed; keeping subscription alive", { - cause: Cause.pretty(cause), + cause, }); }), ); @@ -717,7 +728,7 @@ export const make = Effect.gen(function* () { return yield* Effect.interrupt; } yield* Effect.logError("provider registry refresh failed; preserving cached providers", { - cause: Cause.pretty(cause), + cause, }); return yield* Ref.get(providersRef); }); diff --git a/apps/server/src/provider/ProviderService.test.ts b/apps/server/src/provider/ProviderService.test.ts index 8c020222e07..75c904bcb03 100644 --- a/apps/server/src/provider/ProviderService.test.ts +++ b/apps/server/src/provider/ProviderService.test.ts @@ -30,6 +30,7 @@ import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -1085,6 +1086,18 @@ routing.layer("ProviderServiceLive routing", (it) => { const exit = yield* Effect.exit(provider.listSessions()); assert.equal(Exit.hasDies(exit), true); + const defect = Exit.findDefect(exit); + assert.equal(Result.isSuccess(defect), true); + if (Result.isSuccess(defect)) { + assert.instanceOf(defect.success, ProviderService.ProviderSessionBindingCorrelationError); + assert.deepInclude(defect.success, { + threadId, + sessionProvider: ProviderDriverKind.make("codex"), + bindingProvider: ProviderDriverKind.make("claudeAgent"), + sessionInstanceId: codexInstanceId, + bindingInstanceId: claudeAgentInstanceId, + }); + } yield* directory.upsert({ threadId, provider: ProviderDriverKind.make("codex"), diff --git a/apps/server/src/provider/ProviderService.ts b/apps/server/src/provider/ProviderService.ts index 703b5773965..951ede68831 100644 --- a/apps/server/src/provider/ProviderService.ts +++ b/apps/server/src/provider/ProviderService.ts @@ -21,13 +21,12 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, - type ProviderInstanceId, - type ProviderDriverKind, + ProviderDriverKind, + ProviderInstanceId, type ProviderRuntimeEvent, type ProviderSession, type ProviderTurnStartResult, } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -73,7 +72,49 @@ import { revokeActiveMcpThread, revokeAllActiveMcpCredentials, } from "../mcp/McpSessionRegistry.ts"; -const isModelSelection = Schema.is(ModelSelection); + +export class ProviderBindingInstanceIdMissingError extends Schema.TaggedErrorClass()( + "ProviderBindingInstanceIdMissingError", + { + operation: Schema.String, + provider: Schema.optional(ProviderDriverKind), + }, +) { + override get message(): string { + const provider = this.provider === undefined ? "" : ` for provider '${this.provider}'`; + return `Provider binding is missing an instance id${provider} in ${this.operation}.`; + } +} + +export class ProviderRuntimeEventCorrelationError extends Schema.TaggedErrorClass()( + "ProviderRuntimeEventCorrelationError", + { + sourceInstanceId: ProviderInstanceId, + sourceProvider: ProviderDriverKind, + eventInstanceId: Schema.optional(ProviderInstanceId), + eventProvider: ProviderDriverKind, + }, +) { + override get message(): string { + const eventInstanceId = this.eventInstanceId ?? "unattributed"; + return `Provider runtime event correlation failed for source instance '${this.sourceInstanceId}' (${this.sourceProvider}) and emitted instance '${eventInstanceId}' (${this.eventProvider}).`; + } +} + +export class ProviderSessionBindingCorrelationError extends Schema.TaggedErrorClass()( + "ProviderSessionBindingCorrelationError", + { + threadId: ThreadId, + sessionProvider: ProviderDriverKind, + bindingProvider: ProviderDriverKind, + sessionInstanceId: ProviderInstanceId, + bindingInstanceId: ProviderInstanceId, + }, +) { + override get message(): string { + return `Active provider session and persisted binding disagree for thread '${this.threadId}'.`; + } +} export class ProviderService extends Context.Service< ProviderService, @@ -141,6 +182,8 @@ export class ProviderService extends Context.Service< } >()("t3/provider/ProviderService") {} +const isModelSelection = Schema.is(ModelSelection); + /** * Hook for tests that want to override the canonical event logger pulled * from `ProviderEventLoggers`. Production wiring leaves this undefined and @@ -155,18 +198,6 @@ const ProviderRollbackConversationInput = Schema.Struct({ numTurns: NonNegativeInt, }); -function toValidationError( - operation: string, - issue: string, - cause?: unknown, -): ProviderValidationError { - return new ProviderValidationError({ - operation, - issue, - ...(cause !== undefined ? { cause } : {}), - }); -} - const decodeInputOrValidationError = (input: { readonly operation: string; readonly schema: S; @@ -253,11 +284,10 @@ const dieOnMissingBindingInstanceId = ( if (payload.providerInstanceId !== undefined) { return payload.providerInstanceId; } - throw new Error( - payload.provider - ? `${operation}: provider instance id is required for provider '${payload.provider}'.` - : `${operation}: provider instance id is required.`, - ); + throw new ProviderBindingInstanceIdMissingError({ + operation, + ...(payload.provider === undefined ? {} : { provider: payload.provider }), + }); }; const correlateRuntimeEventWithInstance = ( @@ -267,15 +297,18 @@ const correlateRuntimeEventWithInstance = ( }, event: ProviderRuntimeEvent, ): ProviderRuntimeEvent => { - if (event.provider !== source.provider) { - throw new Error( - `ProviderService.streamEvents: provider instance '${source.instanceId}' is backed by driver '${source.provider}' but emitted driver '${event.provider}'.`, - ); - } - if (event.providerInstanceId !== undefined && event.providerInstanceId !== source.instanceId) { - throw new Error( - `ProviderService.streamEvents: provider instance '${source.instanceId}' emitted event for instance '${event.providerInstanceId}'.`, - ); + if ( + event.provider !== source.provider || + (event.providerInstanceId !== undefined && event.providerInstanceId !== source.instanceId) + ) { + throw new ProviderRuntimeEventCorrelationError({ + sourceInstanceId: source.instanceId, + sourceProvider: source.provider, + ...(event.providerInstanceId === undefined + ? {} + : { eventInstanceId: event.providerInstanceId }), + eventProvider: event.provider, + }); } return { ...event, providerInstanceId: source.instanceId }; }; @@ -325,12 +358,12 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi payload.providerInstanceId !== undefined ? Effect.succeed(payload.providerInstanceId) : Effect.fail( - toValidationError( + new ProviderValidationError({ operation, - payload.provider + issue: payload.provider ? `Provider instance id is required for provider '${payload.provider}'.` : "Provider instance id is required.", - ), + }), ); const upsertSessionBinding = ( @@ -465,10 +498,10 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi } if (!hasResumeCursor) { - return yield* toValidationError( - input.operation, - `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`, - ); + return yield* new ProviderValidationError({ + operation: input.operation, + issue: `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`, + }); } const persistedCwd = readPersistedCwd(input.binding.runtimePayload); @@ -488,10 +521,10 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi .pipe(Effect.onError(() => clearMcpSession(input.binding.threadId))); if (resumed.provider !== adapter.provider) { yield* clearMcpSession(input.binding.threadId); - return yield* toValidationError( - input.operation, - `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, - ); + return yield* new ProviderValidationError({ + operation: input.operation, + issue: `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, + }); } yield* upsertSessionBinding( @@ -522,10 +555,10 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi const bindingOption = yield* directory.getBinding(input.threadId); const binding = Option.getOrUndefined(bindingOption); if (!binding) { - return yield* toValidationError( - input.operation, - `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, - ); + return yield* new ProviderValidationError({ + operation: input.operation, + issue: `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, + }); } const instanceId = yield* requireBindingInstanceId(input.operation, binding); const adapter = yield* registry.getByInstance(instanceId); @@ -620,10 +653,10 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi const resolvedProvider = instanceInfo.driverKind; metricProvider = resolvedProvider; if (parsed.provider !== undefined && parsed.provider !== resolvedProvider) { - return yield* toValidationError( - "ProviderService.startSession", - `Provider instance '${resolvedInstanceId}' belongs to driver '${resolvedProvider}', not '${parsed.provider}'.`, - ); + return yield* new ProviderValidationError({ + operation: "ProviderService.startSession", + issue: `Provider instance '${resolvedInstanceId}' belongs to driver '${resolvedProvider}', not '${parsed.provider}'.`, + }); } const input = { ...parsed, @@ -631,10 +664,10 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi provider: resolvedProvider, }; if (!instanceInfo.enabled) { - return yield* toValidationError( - "ProviderService.startSession", - `Provider instance '${resolvedInstanceId}' is disabled in T3 Code settings.`, - ); + return yield* new ProviderValidationError({ + operation: "ProviderService.startSession", + issue: `Provider instance '${resolvedInstanceId}' is disabled in T3 Code settings.`, + }); } const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); const effectiveResumeCursor = @@ -679,10 +712,10 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi if (session.provider !== adapter.provider) { yield* clearMcpSession(threadId); - return yield* toValidationError( - "ProviderService.startSession", - `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, - ); + return yield* new ProviderValidationError({ + operation: "ProviderService.startSession", + issue: `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, + }); } const sessionWithInstance = { ...session, @@ -732,10 +765,10 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi attachments: parsed.attachments ?? [], }; if (!input.input && input.attachments.length === 0) { - return yield* toValidationError( - "ProviderService.sendTurn", - "Either input text or at least one attachment is required", - ); + return yield* new ProviderValidationError({ + operation: "ProviderService.sendTurn", + issue: "Either input text or at least one attachment is required", + }); } yield* Effect.annotateCurrentSpan({ "provider.operation": "send-turn", @@ -1014,18 +1047,18 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi "ProviderService.listSessions", binding, ); - if (binding.provider !== session.provider) { - return yield* Effect.die( - new Error( - `ProviderService.listSessions: thread '${session.threadId}' is active on provider '${session.provider}' but persisted binding names provider '${binding.provider}'.`, - ), - ); - } - if (overrides.providerInstanceId !== session.providerInstanceId) { + if ( + binding.provider !== session.provider || + overrides.providerInstanceId !== session.providerInstanceId + ) { return yield* Effect.die( - new Error( - `ProviderService.listSessions: thread '${session.threadId}' is active on provider instance '${session.providerInstanceId}' but persisted binding names '${overrides.providerInstanceId}'.`, - ), + new ProviderSessionBindingCorrelationError({ + threadId: session.threadId, + sessionProvider: session.provider, + bindingProvider: binding.provider, + sessionInstanceId: session.providerInstanceId, + bindingInstanceId: overrides.providerInstanceId, + }), ); } if (session.resumeCursor === undefined && binding.resumeCursor !== undefined) { @@ -1139,9 +1172,7 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi yield* Effect.addFinalizer(() => runStopAll().pipe( - Effect.catchCause((cause) => - Effect.logWarning("failed to stop provider service", { cause: Cause.pretty(cause) }), - ), + Effect.catchCause((cause) => Effect.logWarning("failed to stop provider service", { cause })), ), ); diff --git a/apps/server/src/provider/ProviderSessionDirectory.test.ts b/apps/server/src/provider/ProviderSessionDirectory.test.ts index a51d0cf3406..af5d709551b 100644 --- a/apps/server/src/provider/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/ProviderSessionDirectory.test.ts @@ -16,6 +16,7 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../persistence/Layers/Sqlite.ts"; +import { PersistenceSqlError } from "../persistence/Errors.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ProviderSessionDirectory from "./ProviderSessionDirectory.ts"; @@ -28,6 +29,36 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { + const rootCause = new Error("database unavailable"); + const repositoryCause = new PersistenceSqlError({ + operation: "ProviderSessionRuntimeRepository.getByThreadId", + detail: "Failed to read provider session runtime.", + cause: rootCause, + }); + const repository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ + upsert: () => Effect.fail(repositoryCause), + getByThreadId: () => Effect.fail(repositoryCause), + list: () => Effect.fail(repositoryCause), + deleteByThreadId: () => Effect.fail(repositoryCause), + }); + const layer = ProviderSessionDirectory.layer.pipe( + Layer.provide( + Layer.succeed(ProviderSessionRuntime.ProviderSessionRuntimeRepository, repository), + ), + ); + + return Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const error = yield* directory + .getBinding(ThreadId.make("thread-read-failure")) + .pipe(Effect.flip); + + assert.equal(error.operation, "ProviderSessionDirectory.getBinding:getByThreadId"); + assert.equal(error.cause, repositoryCause); + }).pipe(Effect.provide(layer)); +}); + it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => { it("upserts and reads thread bindings", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/ProviderSessionDirectory.ts b/apps/server/src/provider/ProviderSessionDirectory.ts index 3161c8a4de7..a04c17c070f 100644 --- a/apps/server/src/provider/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/ProviderSessionDirectory.ts @@ -67,15 +67,6 @@ export class ProviderSessionDirectory extends Context.Service< const decodeProviderDriverKindValue = Schema.decodeUnknownEffect(ProviderDriverKind); -function toPersistenceError(operation: string) { - return (cause: unknown) => - new ProviderSessionDirectoryPersistenceError({ - operation, - detail: `Failed to execute ${operation}.`, - cause, - }); -} - function decodeProviderDriverKind( providerName: string, operation: string, @@ -139,7 +130,14 @@ export const make = Effect.gen(function* () { const getBinding: ProviderSessionDirectory["Service"]["getBinding"] = (threadId) => repository.getByThreadId({ threadId }).pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getByThreadId")), + Effect.mapError( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.getBinding:getByThreadId", + detail: "Failed to read the persisted provider session binding.", + cause, + }), + ), Effect.flatMap((runtime) => Option.match(runtime, { onNone: () => Effect.succeed(Option.none()), @@ -154,9 +152,16 @@ export const make = Effect.gen(function* () { const upsert: ProviderSessionDirectory["Service"]["upsert"] = Effect.fn( "ProviderSessionDirectory.upsert", )(function* (binding) { - const existing = yield* repository - .getByThreadId({ threadId: binding.threadId }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); + const existing = yield* repository.getByThreadId({ threadId: binding.threadId }).pipe( + Effect.mapError( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.upsert:getByThreadId", + detail: "Failed to read the existing provider session binding before upsert.", + cause, + }), + ), + ); const existingRuntime = Option.getOrUndefined(existing); const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; @@ -198,7 +203,16 @@ export const make = Effect.gen(function* () { binding.runtimePayload, ), }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); + .pipe( + Effect.mapError( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.upsert:upsert", + detail: "Failed to persist the provider session binding.", + cause, + }), + ), + ); }); const getProvider: ProviderSessionDirectory["Service"]["getProvider"] = (threadId) => @@ -219,13 +233,27 @@ export const make = Effect.gen(function* () { const listThreadIds: ProviderSessionDirectory["Service"]["listThreadIds"] = () => repository.list().pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), + Effect.mapError( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.listThreadIds:list", + detail: "Failed to list persisted provider session bindings.", + cause, + }), + ), Effect.map((rows) => rows.map((row) => row.threadId)), ); const listBindings: ProviderSessionDirectory["Service"]["listBindings"] = () => repository.list().pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), + Effect.mapError( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.listBindings:list", + detail: "Failed to list persisted provider session bindings.", + cause, + }), + ), Effect.flatMap((rows) => Effect.forEach( rows, diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 8347ffa04bf..864a6e175be 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -326,7 +326,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { Effect.catchCause((cause) => Effect.logWarning("Provider post-update version verification failed", { provider, - cause: Cause.pretty(cause), + cause, }).pipe( Effect.as({ providers, From 71c6069aa914d08d9002827687cb72018d726dac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:04:05 -0700 Subject: [PATCH 07/20] Use tagged catch for command resolution Co-authored-by: codex --- apps/server/src/provider/providerMaintenance.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 8645f9f943c..fa5e3c3116a 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -357,7 +357,9 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( const env = options?.env ?? (yield* readCommandLookupEnv); const resolvedCommandPath = (yield* resolveCommandPath(binaryPath, { env }).pipe( - Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)), + Effect.catchTags({ + CommandResolutionError: () => Effect.succeed(null), + }), )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (!resolvedCommandPath) { return resolver.resolve(options); From 7c257da84467b8a1bf158bfcb85ed35a155dee6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:10:12 -0700 Subject: [PATCH 08/20] Preserve hydrated provider cache during boot Co-authored-by: codex --- .../src/provider/ProviderRegistry.test.ts | 99 ++++++++++++++++++- apps/server/src/provider/ProviderRegistry.ts | 32 ++++-- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index df2108e8b1c..cff0dc6f09f 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; @@ -42,7 +43,11 @@ import { ProviderInstanceRegistryHydrationLive } from "./Layers/ProviderInstance import * as ProviderRegistry from "./ProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettingsModule from "../serverSettings.ts"; -import { readProviderStatusCache, resolveProviderStatusCachePath } from "./providerStatusCache.ts"; +import { + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "./providerStatusCache.ts"; import type { ProviderInstance } from "./ProviderDriver.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; @@ -849,6 +854,98 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("keeps hydrated cache state while the boot snapshot is still pending", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const fallbackProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-04-29T10:01:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + message: "Codex provider status has not been checked in this session yet.", + } as const satisfies ServerProvider; + const cachedProvider = { + ...fallbackProvider, + status: "ready", + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + message: "Loaded from the provider status cache.", + } as const satisfies ServerProvider; + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(fallbackProvider), + refresh: Effect.succeed(fallbackProvider), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-provider-registry-cache-hydration-", + }); + const configLayer = ServerConfig.layerTest(process.cwd(), baseDir); + const config = yield* ServerConfig.ServerConfig.pipe(Effect.provide(configLayer)); + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: codexInstanceId, + }); + yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); + + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge(configLayer), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + const providers = yield* ProviderRegistry.ProviderRegistry.pipe( + Effect.flatMap((registry) => registry.getProviders), + Effect.provide(runtimeServices), + ); + + assert.deepStrictEqual(providers, [cachedProvider]); + }), + ); + it.effect("returns the cached provider list when a manual refresh fails", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 009f0008944..ef95fcb45d1 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -341,6 +341,10 @@ export const make = Effect.gen(function* () { ), ), ); + const cachedInstanceIds = new Set(cachedProviders.map(snapshotInstanceKey)); + const bootInstancesById = new Map( + bootInstances.map((instance) => [instance.instanceId, instance] as const), + ); const providersRef = yield* Ref.make>(cachedProviders); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap @@ -628,10 +632,22 @@ export const make = Effect.gen(function* () { // or HTTP server construction path. yield* Effect.forEach( newlyAdded, - ([, instance]) => + ([instanceId, instance]) => Effect.gen(function* () { const source = buildSnapshotSource(instance); const provider = yield* source.getSnapshot; + const bootFallback = fallbackByInstance.get(instanceId); + // Keep hydrated cache state when this is still the exact pending + // snapshot read during boot. A probe that completed before the + // subscription was attached differs here and is applied instead. + if ( + cachedInstanceIds.has(instanceId) && + bootInstancesById.get(instanceId) === instance && + bootFallback !== undefined && + Equal.equals(provider, bootFallback) + ) { + return; + } yield* correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)); }).pipe(Effect.ignoreCause({ log: true })), { concurrency: "unbounded", discard: true }, @@ -685,13 +701,13 @@ export const make = Effect.gen(function* () { }), ); - // Seed `providersRef` with the boot-time fallback snapshots so - // consumers calling `getProviders` immediately after layer build see - // a populated list — even before the first `syncLiveSources` refresh - // resolves. Cached snapshots (already in `providersRef`) merge with - // these via `upsertProviders` so on-disk state wins where present - // and pending fallbacks fill the gaps. - yield* upsertProviders(fallbackProviders, { publish: false }); + // Cached snapshots are already in `providersRef`; only seed instances + // without cache entries so pending fallbacks cannot overwrite hydrated + // status while the managed provider's first probe is still running. + yield* upsertProviders( + fallbackProviders.filter((provider) => !cachedInstanceIds.has(snapshotInstanceKey(provider))), + { publish: false }, + ); // Subscribe to registry mutations BEFORE running the initial sync. // `subscribeChanges` acquires the `PubSub.Subscription` synchronously // in this fibre; the subscription is registered with the PubSub the From c9f459697d6f12b006da9937110aa4893c189071 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:37:39 -0700 Subject: [PATCH 09/20] Distinguish initial provider snapshots from live probes Co-authored-by: codex --- .../src/provider/ProviderRegistry.test.ts | 127 +++++++++--------- apps/server/src/provider/ProviderRegistry.ts | 15 ++- .../src/provider/Services/ServerProvider.ts | 9 ++ .../src/provider/builtInProviderCatalog.ts | 1 + .../src/provider/makeManagedServerProvider.ts | 1 + 5 files changed, 87 insertions(+), 66 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index cff0dc6f09f..152da4359da 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -854,7 +854,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); - it.effect("keeps hydrated cache state while the boot snapshot is still pending", () => + it.effect("only keeps hydrated cache state while the boot snapshot is still initial", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const codexDriver = ProviderDriverKind.make("codex"); @@ -882,67 +882,74 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te version: "1.0.0", message: "Loaded from the provider status cache.", } as const satisfies ServerProvider; - const instance = { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - getSnapshot: Effect.succeed(fallbackProvider), - refresh: Effect.succeed(fallbackProvider), - streamChanges: Stream.empty, - }, - adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }, - ); - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-provider-registry-cache-hydration-", - }); - const configLayer = ServerConfig.layerTest(process.cwd(), baseDir); - const config = yield* ServerConfig.ServerConfig.pipe(Effect.provide(configLayer)); - const filePath = yield* resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - instanceId: codexInstanceId, - }); - yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); - - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistry.layer.pipe( - Layer.provideMerge(instanceRegistryLayer), - Layer.provideMerge(configLayer), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); + const loadProviders = (snapshotState: "initial" | "live") => + Effect.gen(function* () { + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(fallbackProvider), + isInitialSnapshot: () => snapshotState === "initial", + refresh: Effect.succeed(fallbackProvider), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: `t3-provider-registry-cache-${snapshotState}-`, + }); + const configLayer = ServerConfig.layerTest(process.cwd(), baseDir); + const config = yield* ServerConfig.ServerConfig.pipe(Effect.provide(configLayer)); + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: codexInstanceId, + }); + yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); + + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + Layer.fresh( + ProviderRegistry.layer.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge(configLayer), + Layer.provideMerge(NodeServices.layer), + ), + ), + ).pipe(Scope.provide(scope)); - const providers = yield* ProviderRegistry.ProviderRegistry.pipe( - Effect.flatMap((registry) => registry.getProviders), - Effect.provide(runtimeServices), - ); + return yield* ProviderRegistry.ProviderRegistry.pipe( + Effect.flatMap((registry) => registry.getProviders), + Effect.provide(runtimeServices), + ); + }); - assert.deepStrictEqual(providers, [cachedProvider]); + assert.deepStrictEqual(yield* loadProviders("initial"), [cachedProvider]); + assert.deepStrictEqual(yield* loadProviders("live"), [fallbackProvider]); }), ); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index ef95fcb45d1..e471caaa2b4 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -257,6 +257,9 @@ const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource instanceId: instance.instanceId, driverKind: instance.driverKind, getSnapshot: instance.snapshot.getSnapshot, + ...(instance.snapshot.isInitialSnapshot === undefined + ? {} + : { isInitialSnapshot: instance.snapshot.isInitialSnapshot }), refresh: instance.snapshot.refresh, streamChanges: instance.snapshot.streamChanges, }); @@ -636,15 +639,15 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const source = buildSnapshotSource(instance); const provider = yield* source.getSnapshot; - const bootFallback = fallbackByInstance.get(instanceId); - // Keep hydrated cache state when this is still the exact pending - // snapshot read during boot. A probe that completed before the - // subscription was attached differs here and is applied instead. + // Keep hydrated cache state only while the original boot instance + // still exposes its explicitly-marked initial snapshot. Comparing + // snapshot values is insufficient: the first probe may have + // completed before registry construction, making the boot-time + // fallback a real live result that must replace stale disk state. if ( cachedInstanceIds.has(instanceId) && bootInstancesById.get(instanceId) === instance && - bootFallback !== undefined && - Equal.equals(provider, bootFallback) + source.isInitialSnapshot?.(provider) === true ) { return; } diff --git a/apps/server/src/provider/Services/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts index 12162512927..3d5153ad699 100644 --- a/apps/server/src/provider/Services/ServerProvider.ts +++ b/apps/server/src/provider/Services/ServerProvider.ts @@ -6,6 +6,15 @@ import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts" export interface ServerProviderShape { readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; readonly getSnapshot: Effect.Effect; + /** + * Identifies the driver's unprobed startup snapshot. Registries use this to + * keep a hydrated disk snapshot only until the first live probe completes. + * + * Optional for compatibility with static/test snapshot sources. A source + * that does not expose lifecycle state is treated as live rather than + * allowing a stale cache entry to mask it. + */ + readonly isInitialSnapshot?: (snapshot: ServerProvider) => boolean; readonly refresh: Effect.Effect; readonly streamChanges: Stream.Stream; } diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts index 559726c65ea..b95dbc807a8 100644 --- a/apps/server/src/provider/builtInProviderCatalog.ts +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -12,6 +12,7 @@ export type ProviderSnapshotSource = { /** Driver implementation kind. */ readonly driverKind: ProviderDriverKind; readonly getSnapshot: ServerProviderShape["getSnapshot"]; + readonly isInitialSnapshot?: (snapshot: ServerProvider) => boolean; readonly refresh: ServerProviderShape["refresh"]; readonly streamChanges: Stream.Stream; }; diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index bbf301fa407..eeb4222f62f 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -153,6 +153,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return { maintenanceCapabilities: input.maintenanceCapabilities, getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), + isInitialSnapshot: (snapshot) => snapshot === initialSnapshot, refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), get streamChanges() { return Stream.fromPubSub(changesPubSub); From d267246cac72d9e917896c61acff66fd295f68d2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:20:22 -0700 Subject: [PATCH 10/20] fix: bound OpenCode failure diagnostics Co-authored-by: codex --- .../provider/Layers/OpenCodeAdapter.test.ts | 11 +- .../src/provider/Layers/OpenCodeAdapter.ts | 24 ++- .../src/provider/Layers/OpenCodeProvider.ts | 4 +- .../src/provider/opencodeRuntime.test.ts | 101 ++++++++++++ apps/server/src/provider/opencodeRuntime.ts | 152 ++++++++++-------- .../textGeneration/OpenCodeTextGeneration.ts | 2 +- 6 files changed, 205 insertions(+), 89 deletions(-) create mode 100644 apps/server/src/provider/opencodeRuntime.test.ts diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 83efe1f2e1f..c4a9f551891 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -369,7 +369,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { runtimeMode: "full-access", }); - runtimeMock.state.promptAsyncError = new Error("prompt failed"); + const promptFailure = new Error("prompt failed"); + runtimeMock.state.promptAsyncError = promptFailure; const error = yield* adapter .sendTurn({ threadId: asThreadId("thread-send-turn-failure"), @@ -386,15 +387,17 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { if (error._tag !== "ProviderAdapterRequestError") { throw new Error("Unexpected error type"); } - NodeAssert.equal(error.detail, "prompt failed"); + NodeAssert.equal(error.detail, "OpenCode SDK request failed."); NodeAssert.equal( error.message, - "Provider adapter request failed (opencode) for session.promptAsync: prompt failed", + "Provider adapter request failed (opencode) for session.promptAsync: OpenCode SDK request failed.", ); + NodeAssert.ok(OpenCodeRuntime.isOpenCodeRuntimeError(error.cause)); + NodeAssert.strictEqual(error.cause.cause, promptFailure); NodeAssert.equal(sessions.length, 1); NodeAssert.equal(sessions[0]?.status, "ready"); NodeAssert.equal(sessions[0]?.activeTurnId, undefined); - NodeAssert.equal(sessions[0]?.lastError, "prompt failed"); + NodeAssert.equal(sessions[0]?.lastError, "OpenCode SDK request failed."); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index ff8be6da31b..00da4273ff8 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -106,22 +106,20 @@ const toRequestError = (cause: OpenCodeRuntime.OpenCodeRuntimeError): ProviderAd provider: PROVIDER, method: cause.operation, detail: cause.detail, - cause: cause.cause, + cause, }); /** * Map a `Cause.squash`-ed failure into a `ProviderAdapterProcessError`. The * typed cause is usually an `OpenCodeRuntimeError` (from {@link OpenCodeRuntime.runOpenCodeSdk}), * in which case we preserve its `detail`; otherwise we fall back to - * {@link OpenCodeRuntime.openCodeRuntimeErrorDetail} for unknown causes (defects, etc.). + * {@link OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause} for unknown causes (defects, etc.). */ const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProcessError => new ProviderAdapterProcessError({ provider: PROVIDER, threadId, - detail: OpenCodeRuntime.isOpenCodeRuntimeError(cause) - ? cause.detail - : OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause(cause), cause, }); @@ -971,14 +969,12 @@ export function makeOpenCodeAdapter( }), ), (subscription) => - Stream.fromAsyncIterable( - subscription.stream, - (cause) => - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "event.subscribe", - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - cause, - }), + Stream.fromAsyncIterable(subscription.stream, (cause) => + OpenCodeRuntime.OpenCodeRuntimeError.fromCause({ + operation: "event.subscribe", + detail: "OpenCode event subscription failed.", + cause, + }), ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), ).pipe( Effect.exit, @@ -992,7 +988,7 @@ export function makeOpenCodeAdapter( if (Exit.isFailure(exit)) { yield* emitUnexpectedExit( context, - OpenCodeRuntime.openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), + OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause(Cause.squash(exit.cause)), ); } }), diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index acbe7e495ad..e2685033e6e 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -377,7 +377,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu (cause) => new OpenCodeProbeError({ cause, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause(cause), }), ), ), @@ -440,7 +440,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu (cause) => new OpenCodeProbeError({ cause, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause(cause), }), ), ), diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts new file mode 100644 index 00000000000..5b54d91226a --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -0,0 +1,101 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { make, OpenCodeRuntimeError, runOpenCodeSdk } from "./opencodeRuntime.ts"; + +const encoder = new TextEncoder(); + +function exitedProcess(stdout: string, stderr: string, exitCode: number) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.make(encoder.encode(stdout)), + stderr: Stream.make(encoder.encode(stderr)), + all: Stream.empty, + exitCode: Effect.yieldNow.pipe( + Effect.andThen(Effect.succeed(ChildProcessSpawner.ExitCode(exitCode))), + ), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); +} + +const netService = { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(12_345), + findAvailablePort: () => Effect.succeed(12_345), +} satisfies NetService.NetServiceShape; + +describe("OpenCodeRuntime", () => { + it.effect("retains SDK status and cause without copying arbitrary response bodies", () => { + const cause = { + response: { status: 401 }, + body: { accessToken: "sdk-secret" }, + }; + + return Effect.gen(function* () { + const result = yield* Effect.result( + runOpenCodeSdk("session.get", () => Promise.reject(cause)), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, OpenCodeRuntimeError); + assert.equal(result.failure.operation, "session.get"); + assert.equal(result.failure.detail, "OpenCode SDK request failed."); + assert.equal(result.failure.responseStatus, 401); + assert.strictEqual(result.failure.cause, cause); + assert.notInclude(result.failure.detail, "sdk-secret"); + assert.isFalse("body" in result.failure); + } + }); + }); + + it.live("records startup output sizes without retaining process streams", () => { + const stdout = "startup output with a credential sdk-secret"; + const stderr = "startup failed"; + const layer = Layer.mergeAll( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(exitedProcess(stdout, stderr, 17))), + ), + Layer.succeed(NetService.NetService, netService), + Layer.succeed(HostProcessPlatform, "win32"), + ); + + return Effect.gen(function* () { + const runtime = yield* make; + const result = yield* Effect.result( + Effect.scoped( + runtime.startOpenCodeServerProcess({ + binaryPath: "opencode", + port: 12_345, + }), + ), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, OpenCodeRuntimeError); + assert.equal(result.failure.exitCode, 17); + assert.equal(result.failure.argumentCount, 3); + assert.equal(result.failure.stdoutBytes, encoder.encode(stdout).byteLength); + assert.equal(result.failure.stderrBytes, encoder.encode(stderr).byteLength); + assert.isFalse("stdout" in result.failure); + assert.isFalse("stderr" in result.failure); + assert.notInclude(result.failure.detail, "sdk-secret"); + } + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 35422336e78..e877fbc978a 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -11,6 +11,9 @@ import { type QuestionAnswer, type QuestionRequest, } from "@opencode-ai/sdk/v2"; +import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; @@ -29,10 +32,8 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; -import * as NetService from "@t3tools/shared/Net"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); + +const encoder = new TextEncoder(); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; @@ -56,36 +57,60 @@ export class OpenCodeRuntimeError extends Schema.TaggedErrorClass>).response; + if (typeof response !== "object" || response === null) { + return undefined; + } + const status = (response as Readonly>).status; + return typeof status === "number" && Number.isInteger(status) ? status : undefined; } -export function openCodeRuntimeErrorDetail(cause: unknown): string { - if (isOpenCodeRuntimeError(cause)) return cause.detail; - if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); - if (cause && typeof cause === "object") { - // SDK v2 throws { response, request, error? } shapes — extract what's useful - const anyCause = cause as Record; - const status = (anyCause.response as { status?: number } | undefined)?.status; - const body = anyCause.error ?? anyCause.data ?? anyCause.body; - const encodedBody = encodeJsonStringForDiagnostics(body ?? cause); - if (encodedBody) { - return `status=${status ?? "?"} body=${encodedBody}`; - } - } - return String(cause); +function utf8ByteLength(value: string): number { + return encoder.encode(value).byteLength; } export const runOpenCodeSdk = ( @@ -95,7 +120,11 @@ export const runOpenCodeSdk = ( Effect.tryPromise({ try: fn, catch: (cause) => - new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), + OpenCodeRuntimeError.fromCause({ + operation, + detail: "OpenCode SDK request failed.", + cause, + }), }).pipe(Effect.withSpan(`opencode.${operation}`)); export interface OpenCodeCommandResult { @@ -275,16 +304,6 @@ export function toOpenCodeQuestionAnswers( }); } -function ensureRuntimeError( - operation: OpenCodeRuntimeError["operation"], - detail: string, - cause: unknown, -): OpenCodeRuntimeError { - return isOpenCodeRuntimeError(cause) - ? cause - : new OpenCodeRuntimeError({ operation, detail, cause }); -} - export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService.NetService; @@ -309,7 +328,8 @@ export const make = Effect.gen(function* () { if (yield* isWindowsCommandNotFound(exitCode, stderr)) { return yield* new OpenCodeRuntimeError({ operation: "runOpenCodeCommand", - detail: `spawn ${input.binaryPath} ENOENT`, + detail: "OpenCode executable was not found.", + argumentCount: input.args.length, }); } return { @@ -320,11 +340,12 @@ export const make = Effect.gen(function* () { }).pipe( Effect.scoped, Effect.mapError((cause) => - ensureRuntimeError( - "runOpenCodeCommand", - `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, + OpenCodeRuntimeError.fromCause({ + operation: "runOpenCodeCommand", + detail: "Failed to execute OpenCode command.", cause, - ), + argumentCount: input.args.length, + }), ), ); @@ -341,13 +362,12 @@ export const make = Effect.gen(function* () { const port = input.port ?? (yield* netService.findAvailablePort(0).pipe( - Effect.mapError( - (cause) => - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - }), + Effect.mapError((cause) => + OpenCodeRuntimeError.fromCause({ + operation: "startOpenCodeServerProcess", + detail: "Failed to find an available OpenCode server port.", + cause, + }), ), )); const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; @@ -368,13 +388,13 @@ export const make = Effect.gen(function* () { ) .pipe( Effect.provideService(Scope.Scope, runtimeScope), - Effect.mapError( - (cause) => - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - }), + Effect.mapError((cause) => + OpenCodeRuntimeError.fromCause({ + operation: "startOpenCodeServerProcess", + detail: "Failed to spawn OpenCode server process.", + cause, + argumentCount: args.length, + }), ), ); @@ -434,16 +454,11 @@ export const make = Effect.gen(function* () { readyDeferred, new OpenCodeRuntimeError({ operation: "startOpenCodeServerProcess", - detail: [ - `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, - stdout.trim() ? `stdout:\n${stdout.trim()}` : null, - stderr.trim() ? `stderr:\n${stderr.trim()}` : null, - ] - .filter(Boolean) - .join("\n\n"), + detail: "OpenCode server exited before startup completed.", exitCode, - stdout, - stderr, + argumentCount: args.length, + stdoutBytes: utf8ByteLength(stdout), + stderrBytes: utf8ByteLength(stderr), }), ).pipe(Effect.ignore); }), @@ -465,11 +480,11 @@ export const make = Effect.gen(function* () { if (Exit.isFailure(readyExit)) { yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); const squashed = Cause.squash(readyExit.cause); - return yield* ensureRuntimeError( - "startOpenCodeServerProcess", - `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, - squashed, - ); + return yield* OpenCodeRuntimeError.fromCause({ + operation: "startOpenCodeServerProcess", + detail: "Failed while waiting for OpenCode server startup.", + cause: squashed, + }); } const readyOption = readyExit.value; @@ -477,7 +492,8 @@ export const make = Effect.gen(function* () { yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); return yield* new OpenCodeRuntimeError({ operation: "startOpenCodeServerProcess", - detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, + detail: "Timed out waiting for OpenCode server startup.", + timeoutMs, }); } diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 1f94f970692..d6c782630d6 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -309,7 +309,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" (cause) => new TextGenerationError({ operation: input.operation, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause(cause), cause, }), ), From 65ff046bdf8ee8fd923579eea9bb2a456a8e17b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:12:08 -0700 Subject: [PATCH 11/20] fix(provider): sanitize OpenCode endpoint messages Co-authored-by: codex --- .../src/provider/Layers/OpenCodeProvider.test.ts | 6 +++++- .../src/provider/Layers/OpenCodeProvider.ts | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index f754d4ab9b2..1d2c2ace968 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -210,7 +210,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i runtimeMock.state.inventoryError = new Error("401 Unauthorized"); const snapshot = yield* checkOpenCodeProviderStatus( makeOpenCodeSettings({ - serverUrl: "http://127.0.0.1:9999", + serverUrl: "http://url-user:url-password@127.0.0.1:9999/private?token=secret#fragment", serverPassword: "secret-password", }), process.cwd(), @@ -244,6 +244,10 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i snapshot.message, "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.", ); + NodeAssert.doesNotMatch( + snapshot.message ?? "", + /url-user|url-password|private|token|secret|fragment/, + ); }), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index e2685033e6e..c72d4f7c5a0 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -59,6 +59,16 @@ function normalizedErrorMessage(cause: unknown): string | undefined { return normalizeProbeMessage(cause.message); } +function openCodeServerDisplayTarget(input: string): string | undefined { + try { + const url = new URL(input); + const port = url.port === "" ? "" : `:${url.port}`; + return `${url.protocol}//${url.hostname}${port}`; + } catch { + return undefined; + } +} + function formatOpenCodeProbeError(input: { readonly cause: unknown; readonly isExternalServer: boolean; @@ -89,9 +99,13 @@ function formatOpenCodeProbeError(input: { lower.includes("timeout") || lower.includes("socket hang up") ) { + const serverTarget = openCodeServerDisplayTarget(input.serverUrl); return { installed: true, - message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, + message: + serverTarget === undefined + ? "Couldn't reach the configured OpenCode server. Check that the server is running and the URL is correct." + : `Couldn't reach the configured OpenCode server at ${serverTarget}. Check that the server is running and the URL is correct.`, }; } From 6524964785d95df36c37c0bbb2399a088bb56eac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:09:27 -0700 Subject: [PATCH 12/20] fix(provider): preserve IPv6 display targets Co-authored-by: codex --- .../provider/Layers/OpenCodeProvider.test.ts | 18 ++++++++++++++++++ .../src/provider/Layers/OpenCodeProvider.ts | 3 +-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 1d2c2ace968..de589703017 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -250,4 +250,22 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i ); }), ); + + it.effect("keeps IPv6 brackets in configured server diagnostics", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error("fetch failed: connect ECONNREFUSED ::1:9999"); + const snapshot = yield* checkOpenCodeProviderStatus( + makeOpenCodeSettings({ + serverUrl: "http://[::1]:9999/private", + serverPassword: "secret-password", + }), + process.cwd(), + ); + + NodeAssert.equal( + snapshot.message, + "Couldn't reach the configured OpenCode server at http://[::1]:9999. Check that the server is running and the URL is correct.", + ); + }), + ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index c72d4f7c5a0..4ac0dff8558 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -62,8 +62,7 @@ function normalizedErrorMessage(cause: unknown): string | undefined { function openCodeServerDisplayTarget(input: string): string | undefined { try { const url = new URL(input); - const port = url.port === "" ? "" : `:${url.port}`; - return `${url.protocol}//${url.hostname}${port}`; + return `${url.protocol}//${url.host}`; } catch { return undefined; } From 4934dc2ee6728652c40c719f70bdfb3b3b6634d8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:21:06 -0700 Subject: [PATCH 13/20] test(server): remove cause-only provider assertion Co-authored-by: codex --- .../provider/ProviderSessionDirectory.test.ts | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/apps/server/src/provider/ProviderSessionDirectory.test.ts b/apps/server/src/provider/ProviderSessionDirectory.test.ts index af5d709551b..a51d0cf3406 100644 --- a/apps/server/src/provider/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/ProviderSessionDirectory.test.ts @@ -16,7 +16,6 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../persistence/Layers/Sqlite.ts"; -import { PersistenceSqlError } from "../persistence/Errors.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ProviderSessionDirectory from "./ProviderSessionDirectory.ts"; @@ -29,36 +28,6 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { - const rootCause = new Error("database unavailable"); - const repositoryCause = new PersistenceSqlError({ - operation: "ProviderSessionRuntimeRepository.getByThreadId", - detail: "Failed to read provider session runtime.", - cause: rootCause, - }); - const repository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ - upsert: () => Effect.fail(repositoryCause), - getByThreadId: () => Effect.fail(repositoryCause), - list: () => Effect.fail(repositoryCause), - deleteByThreadId: () => Effect.fail(repositoryCause), - }); - const layer = ProviderSessionDirectory.layer.pipe( - Layer.provide( - Layer.succeed(ProviderSessionRuntime.ProviderSessionRuntimeRepository, repository), - ), - ); - - return Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; - const error = yield* directory - .getBinding(ThreadId.make("thread-read-failure")) - .pipe(Effect.flip); - - assert.equal(error.operation, "ProviderSessionDirectory.getBinding:getByThreadId"); - assert.equal(error.cause, repositoryCause); - }).pipe(Effect.provide(layer)); -}); - it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => { it("upserts and reads thread bindings", () => Effect.gen(function* () { From d59b5911cdebd114390fdcf9c9cfaaa0b4dbe9b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 13:31:28 -0700 Subject: [PATCH 14/20] Classify missing provider bindings Co-authored-by: codex --- apps/server/src/provider/Errors.ts | 2 ++ .../provider/ProviderSessionDirectory.test.ts | 12 +++++++++++ .../src/provider/ProviderSessionDirectory.ts | 20 +++++++++++++------ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index 0cf1522399b..de15556153d 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -171,6 +171,8 @@ export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass(persistenceLayer: Layer.Layer) { @@ -76,6 +77,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.deepEqual(threadIds, [nextThreadId]); })); + it("reports a missing provider binding as a domain not-found error", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = ThreadId.make("thread-missing"); + + const error = yield* directory.getProvider(threadId).pipe(Effect.flip); + + assert(isProviderSessionNotFoundError(error)); + assert.equal(error.threadId, threadId); + })); + it("persists runtime fields and merges payload updates", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; diff --git a/apps/server/src/provider/ProviderSessionDirectory.ts b/apps/server/src/provider/ProviderSessionDirectory.ts index a04c17c070f..dc777ce9198 100644 --- a/apps/server/src/provider/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/ProviderSessionDirectory.ts @@ -13,7 +13,11 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "./Errors.ts"; +import { + ProviderSessionDirectoryPersistenceError, + ProviderSessionNotFoundError, + ProviderValidationError, +} from "./Errors.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; export interface ProviderRuntimeBinding { @@ -36,7 +40,9 @@ export interface ProviderRuntimeBindingWithMetadata extends ProviderRuntimeBindi readonly lastSeenAt: string; } -export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; +export type ProviderSessionDirectoryReadError = + | ProviderSessionDirectoryPersistenceError + | ProviderSessionNotFoundError; export type ProviderSessionDirectoryWriteError = | ProviderValidationError @@ -53,7 +59,10 @@ export class ProviderSessionDirectory extends Context.Service< ) => Effect.Effect; readonly getBinding: ( threadId: ThreadId, - ) => Effect.Effect, ProviderSessionDirectoryReadError>; + ) => Effect.Effect< + Option.Option, + ProviderSessionDirectoryPersistenceError + >; readonly listThreadIds: () => Effect.Effect< ReadonlyArray, ProviderSessionDirectoryPersistenceError @@ -222,9 +231,8 @@ export const make = Effect.gen(function* () { onSome: (value) => Effect.succeed(value.provider), onNone: () => Effect.fail( - new ProviderSessionDirectoryPersistenceError({ - operation: "ProviderSessionDirectory.getProvider", - detail: `No persisted provider binding found for thread '${threadId}'.`, + new ProviderSessionNotFoundError({ + threadId, }), ), }), From c56fe69bee3a25ba7a706180584deef5b31df798 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 13:41:33 -0700 Subject: [PATCH 15/20] Remove provider error constructor indirection Co-authored-by: codex --- .../TestProviderAdapter.integration.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 0e64699de97..230e770ce37 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -206,21 +206,16 @@ function nowIso(): string { return "2026-01-01T00:00:00.000Z"; } -function sessionNotFound( - provider: ProviderDriverKind, - threadId: ThreadId, -): ProviderAdapterSessionNotFoundError { - return new ProviderAdapterSessionNotFoundError({ - provider, - threadId: String(threadId), - }); -} - function missingSessionEffect( provider: ProviderDriverKind, threadId: ThreadId, ): Effect.Effect { - return Effect.fail(sessionNotFound(provider, threadId)); + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider, + threadId: String(threadId), + }), + ); } export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => @@ -517,7 +512,12 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter ? Effect.sync(() => { state.queuedResponses.push(response); }) - : Effect.fail(sessionNotFound(provider, threadId)), + : Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider, + threadId: String(threadId), + }), + ), ), ); From 44a94f461eacea0fa290caaa2936864854804db3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:08:23 -0700 Subject: [PATCH 16/20] Cover whitespace-only provider turn validation Co-authored-by: codex --- .../src/provider/ProviderService.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/server/src/provider/ProviderService.test.ts b/apps/server/src/provider/ProviderService.test.ts index 75c904bcb03..47e7f4d9646 100644 --- a/apps/server/src/provider/ProviderService.test.ts +++ b/apps/server/src/provider/ProviderService.test.ts @@ -1810,6 +1810,25 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { + it.effect("rejects whitespace-only turns without attachments", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + + validation.codex.sendTurn.mockClear(); + const failure = yield* provider + .sendTurn({ + threadId: asThreadId("thread-whitespace-only"), + input: " \n\t ", + attachments: [], + }) + .pipe(Effect.flip); + + assert.instanceOf(failure, ProviderValidationError); + assert.equal(failure.operation, "ProviderService.sendTurn"); + assert.equal(validation.codex.sendTurn.mock.calls.length, 0); + }), + ); + it.effect("rejects session starts without an explicit provider instance id", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; From 6830618443e4b10bae09fa464992939b286695e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:05:00 -0700 Subject: [PATCH 17/20] Structure OpenCode probe failures Co-authored-by: codex --- .../provider/Layers/OpenCodeProvider.test.ts | 20 +++++++- .../src/provider/Layers/OpenCodeProvider.ts | 51 ++++++++++--------- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index de589703017..803593327ad 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -10,7 +10,7 @@ import { beforeEach } from "vite-plus/test"; import { OpenCodeSettings } from "@t3tools/contracts"; import * as Config from "../../config.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; -import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; +import { checkOpenCodeProviderStatus, OpenCodeProbeError } from "./OpenCodeProvider.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); @@ -114,6 +114,24 @@ const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSe ...overrides, }); +it("structures OpenCode probe failures without deriving messages from causes", () => { + const cause = new Error("401 Unauthorized"); + const runtimeError = new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: cause.message, + cause, + }); + const probeError = OpenCodeProbeError.fromCause("load-inventory")(runtimeError); + const versionProbeError = OpenCodeProbeError.fromCause("probe-version")(runtimeError); + + NodeAssert.equal(probeError.operation, "load-inventory"); + NodeAssert.equal(probeError.detail, "401 Unauthorized"); + NodeAssert.strictEqual(probeError.cause, runtimeError); + NodeAssert.strictEqual(runtimeError.cause, cause); + NodeAssert.equal(probeError.message, "OpenCode probe failed during load-inventory."); + NodeAssert.equal(versionProbeError.message, "OpenCode probe failed during probe-version."); +}); + it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("shows a codex-style missing binary message", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 4ac0dff8558..7c766894c96 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -5,9 +5,9 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; @@ -28,10 +28,29 @@ const OPENCODE_PRESENTATION = { } as const; const MINIMUM_OPENCODE_VERSION = "1.14.19"; -class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ - readonly cause: unknown; - readonly detail: string; -}> {} +export class OpenCodeProbeError extends Schema.TaggedErrorClass()( + "OpenCodeProbeError", + { + operation: Schema.Literals(["probe-version", "load-inventory"]), + detail: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode probe failed during ${this.operation}.`; + } + + static fromCause(operation: "probe-version" | "load-inventory") { + return (cause: unknown): OpenCodeProbeError => + new OpenCodeProbeError({ + operation, + detail: OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause(cause), + cause, + }); + } +} + +export const isOpenCodeProbeError = Schema.is(OpenCodeProbeError); function normalizeProbeMessage(message: string): string | undefined { const trimmed = message.trim(); @@ -48,7 +67,7 @@ function normalizeProbeMessage(message: string): string | undefined { } function normalizedErrorMessage(cause: unknown): string | undefined { - if (cause instanceof OpenCodeProbeError) { + if (isOpenCodeProbeError(cause)) { return normalizeProbeMessage(cause.detail); } @@ -385,15 +404,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu args: ["--version"], environment: resolvedEnvironment, }) - .pipe( - Effect.mapError( - (cause) => - new OpenCodeProbeError({ - cause, - detail: OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause(cause), - }), - ), - ), + .pipe(Effect.mapError(OpenCodeProbeError.fromCause("probe-version"))), ); if (versionExit._tag === "Failure") { return fallback(Cause.squash(versionExit.cause)); @@ -448,15 +459,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu : {}), }), ); - }).pipe( - Effect.mapError( - (cause) => - new OpenCodeProbeError({ - cause, - detail: OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause(cause), - }), - ), - ), + }).pipe(Effect.mapError(OpenCodeProbeError.fromCause("load-inventory"))), ), ); if (inventoryExit._tag === "Failure") { From 2c81a9bfc43f4bb61cd50ac4555da59fa09f2aaa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:33:58 -0700 Subject: [PATCH 18/20] Preserve provider update error causes Co-authored-by: codex --- apps/server/src/provider/providerMaintenanceRunner.test.ts | 4 ++++ apps/server/src/provider/providerMaintenanceRunner.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 2322318359b..f06c97fedd0 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -461,6 +461,10 @@ describe("providerMaintenanceRunner", () => { assert.strictEqual(isServerProviderUpdateError(error), true); if (isServerProviderUpdateError(error)) { assert.include(error.reason, "already running"); + assert.strictEqual(isServerProviderUpdateError(error.cause), true); + if (isServerProviderUpdateError(error.cause)) { + assert.strictEqual(error.cause.provider, "unknown"); + } } } diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 864a6e175be..4fc6780f2e9 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -465,6 +465,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ? new ServerProviderUpdateError({ provider, reason: error.reason, + cause: error, }) : error, ), From 2e124c92afb7ca98721b03fcc82a5ab8bb8d4040 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:37:09 -0700 Subject: [PATCH 19/20] Reject whitespace-only provider turns Co-authored-by: codex --- apps/server/src/provider/ProviderService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/ProviderService.ts b/apps/server/src/provider/ProviderService.ts index 951ede68831..906600f4cd7 100644 --- a/apps/server/src/provider/ProviderService.ts +++ b/apps/server/src/provider/ProviderService.ts @@ -764,7 +764,7 @@ export const make = Effect.fn("ProviderService.make")(function* (options?: Provi ...parsed, attachments: parsed.attachments ?? [], }; - if (!input.input && input.attachments.length === 0) { + if (!input.input?.trim() && input.attachments.length === 0) { return yield* new ProviderValidationError({ operation: "ProviderService.sendTurn", issue: "Either input text or at least one attachment is required", From 60a235bc9afd9282667a571b246a89b08c4944f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:16:03 -0700 Subject: [PATCH 20/20] Structure provider adapter failure context Co-authored-by: codex --- apps/server/src/provider/Errors.ts | 5 ++-- .../src/provider/Layers/ClaudeAdapter.test.ts | 1 + .../src/provider/Layers/ClaudeAdapter.ts | 4 +++ .../src/provider/Layers/CodexAdapter.ts | 10 +++++--- .../src/provider/Layers/CursorAdapter.ts | 1 + .../server/src/provider/Layers/GrokAdapter.ts | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 2 +- .../src/provider/Layers/OpenCodeAdapter.ts | 25 +++++++------------ .../provider/acp/AcpAdapterSupport.test.ts | 15 +++++++---- .../src/provider/acp/AcpAdapterSupport.ts | 11 +------- .../src/provider/opencodeRuntime.test.ts | 10 ++++---- 11 files changed, 42 insertions(+), 43 deletions(-) diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index de15556153d..2a7c8f998e1 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -64,7 +64,7 @@ export class ProviderAdapterRequestError extends Schema.TaggedErrorClass { .pipe(Effect.flip); assert.instanceOf(error, ProviderAdapterProcessError); + assert.equal(error.stage, "session-start"); assert.equal(error.detail, "Failed to start Claude runtime session."); assert.strictEqual(error.cause, cause); assert.notMatch(error.message, /credential material/u); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 97a93f85829..6ec6d0166d1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2893,6 +2893,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: context.session.threadId, + stage: "runtime-stream", detail: "Claude runtime stream failed.", cause, }), @@ -2905,6 +2906,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: context.session.threadId, + stage: "runtime-event", detail: "Failed to process Claude runtime event.", cause, }), @@ -2992,6 +2994,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: context.session.threadId, + stage: "runtime-query-close", detail: "Failed to close Claude runtime query.", cause, }), @@ -3515,6 +3518,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId, + stage: "session-start", detail: "Failed to start Claude runtime session.", cause, }), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 270126e934b..e8cca69b9eb 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -116,7 +116,7 @@ function mapCodexRuntimeError( return new ProviderAdapterRequestError({ provider: PROVIDER, method, - detail: error.message, + detail: "Codex runtime request failed.", cause: error, }); } @@ -1432,7 +1432,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, - detail: cause.message, + stage: "session-runtime-create", + detail: "Failed to create Codex runtime session.", cause, }), ), @@ -1461,7 +1462,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, - detail: cause.message, + stage: "session-start", + detail: "Failed to start Codex runtime session.", cause, }), ), @@ -1508,7 +1510,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( new ProviderAdapterRequestError({ provider: PROVIDER, method: "turn/start", - detail: `Failed to read attachment file: ${cause.message}.`, + detail: "Failed to read attachment file.", cause, }), ), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 991dd5497a9..6e4baf85d21 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -564,6 +564,7 @@ export function makeCursorAdapter( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, + stage: "session-start", detail: "Failed to start the Cursor ACP session process.", cause, }), diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 13f275238d2..459f30ea868 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -408,6 +408,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, + stage: "session-start", detail: "Failed to start the Grok ACP session process.", cause, }), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index c4a9f551891..02247d82553 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -390,7 +390,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { NodeAssert.equal(error.detail, "OpenCode SDK request failed."); NodeAssert.equal( error.message, - "Provider adapter request failed (opencode) for session.promptAsync: OpenCode SDK request failed.", + "Provider adapter request failed (opencode) for session.promptAsync.", ); NodeAssert.ok(OpenCodeRuntime.isOpenCodeRuntimeError(error.cause)); NodeAssert.strictEqual(error.cause.cause, promptFailure); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 00da4273ff8..91580266d58 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -105,21 +105,7 @@ const toRequestError = (cause: OpenCodeRuntime.OpenCodeRuntimeError): ProviderAd new ProviderAdapterRequestError({ provider: PROVIDER, method: cause.operation, - detail: cause.detail, - cause, - }); - -/** - * Map a `Cause.squash`-ed failure into a `ProviderAdapterProcessError`. The - * typed cause is usually an `OpenCodeRuntimeError` (from {@link OpenCodeRuntime.runOpenCodeSdk}), - * in which case we preserve its `detail`; otherwise we fall back to - * {@link OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause} for unknown causes (defects, etc.). - */ -const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProcessError => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId, - detail: OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause(cause), + detail: "OpenCode SDK request failed.", cause, }); @@ -1078,7 +1064,14 @@ export function makeOpenCodeAdapter( ); if (Exit.isFailure(startedExit)) { yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); - return yield* toProcessError(input.threadId, Cause.squash(startedExit.cause)); + const cause = Cause.squash(startedExit.cause); + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + stage: "session-start", + detail: "Failed to start OpenCode runtime session.", + cause, + }); } return startedExit.value; }); diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts index 0aebe0ca6d8..0f8eab8b857 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -12,17 +12,22 @@ describe("AcpAdapterSupport", () => { }); it("maps ACP request errors to provider adapter request errors", () => { + const cause = new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: "Invalid params", + }); const error = mapAcpToAdapterError( ProviderDriverKind.make("cursor"), "thread-1" as never, "session/prompt", - new EffectAcpErrors.AcpRequestError({ - code: -32602, - errorMessage: "Invalid params", - }), + cause, ); expect(error._tag).toBe("ProviderAdapterRequestError"); - expect(error.message).toContain("Invalid params"); + expect(error.message).toBe("Provider adapter request failed (cursor) for session/prompt."); + if (error._tag === "ProviderAdapterRequestError") { + expect(error.detail).toBe("ACP request failed."); + expect(error.cause).toBe(cause); + } }); }); diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts index cde110e6dd9..622ac3619f4 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -12,7 +12,6 @@ import { type ProviderAdapterError, } from "../Errors.ts"; const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError); -const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); export function mapAcpToAdapterError( provider: ProviderDriverKind, @@ -27,18 +26,10 @@ export function mapAcpToAdapterError( cause: error, }); } - if (isAcpRequestError(error)) { - return new ProviderAdapterRequestError({ - provider, - method, - detail: error.message, - cause: error, - }); - } return new ProviderAdapterRequestError({ provider, method, - detail: error.message, + detail: "ACP request failed.", cause: error, }); } diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts index 5b54d91226a..79bd7c6f6ca 100644 --- a/apps/server/src/provider/opencodeRuntime.test.ts +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -8,7 +8,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import { make, OpenCodeRuntimeError, runOpenCodeSdk } from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; const encoder = new TextEncoder(); @@ -46,12 +46,12 @@ describe("OpenCodeRuntime", () => { return Effect.gen(function* () { const result = yield* Effect.result( - runOpenCodeSdk("session.get", () => Promise.reject(cause)), + OpenCodeRuntime.runOpenCodeSdk("session.get", () => Promise.reject(cause)), ); assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { - assert.instanceOf(result.failure, OpenCodeRuntimeError); + assert.instanceOf(result.failure, OpenCodeRuntime.OpenCodeRuntimeError); assert.equal(result.failure.operation, "session.get"); assert.equal(result.failure.detail, "OpenCode SDK request failed."); assert.equal(result.failure.responseStatus, 401); @@ -75,7 +75,7 @@ describe("OpenCodeRuntime", () => { ); return Effect.gen(function* () { - const runtime = yield* make; + const runtime = yield* OpenCodeRuntime.make; const result = yield* Effect.result( Effect.scoped( runtime.startOpenCodeServerProcess({ @@ -87,7 +87,7 @@ describe("OpenCodeRuntime", () => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { - assert.instanceOf(result.failure, OpenCodeRuntimeError); + assert.instanceOf(result.failure, OpenCodeRuntime.OpenCodeRuntimeError); assert.equal(result.failure.exitCode, 17); assert.equal(result.failure.argumentCount, 3); assert.equal(result.failure.stdoutBytes, encoder.encode(stdout).byteLength);