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/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), + }), + ), ), ); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index e703af4b1f4..c9026ab95ab 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,22 @@ 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 = ProviderService.layer.pipe(Layer.provide(shared)); return { cwd, @@ -105,7 +101,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 +125,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 +162,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 +199,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 +251,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/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/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f2b04b3a282..2fec86194c2 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -20,12 +20,12 @@ 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 { 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, @@ -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 ffcc94ca77d..6faaeebb162 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -28,16 +28,16 @@ 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 { 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); @@ -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 c394a7d1b43..eb055e575da 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -18,11 +18,11 @@ 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 { 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, @@ -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 d855d1a4515..5c249d9e296 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -5,11 +5,11 @@ 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 { 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, @@ -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 6342d176590..7e6516c520e 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -19,21 +19,21 @@ 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 { 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>( @@ -174,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/Errors.ts b/apps/server/src/provider/Errors.ts index 0cf1522399b..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.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/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 9760b2f81fb..6e4baf85d21 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 ?? @@ -564,7 +564,8 @@ export function makeCursorAdapter( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, - detail: cause.message, + stage: "session-start", + detail: "Failed to start the Cursor ACP session process.", cause, }), ), @@ -979,7 +980,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 40f425cbaa1..459f30ea868 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 ?? @@ -408,7 +408,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, - detail: cause.message, + stage: "session-start", + detail: "Failed to start the Grok ACP session process.", cause, }), ), @@ -736,7 +737,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/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index d0475e25284..02247d82553 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), ); @@ -366,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"), @@ -383,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.", ); + 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."); }), ); @@ -480,9 +486,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 +533,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 +575,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 +798,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 +882,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/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 1eb6e47bc19..91580266d58 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,19 +36,7 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { - buildOpenCodePermissionRules, - 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"); @@ -68,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; @@ -108,30 +96,16 @@ 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, - detail: cause.detail, - cause: cause.cause, - }); - -/** - * Map a `Cause.squash`-ed failure into a `ProviderAdapterProcessError`. The - * typed cause is usually an `OpenCodeRuntimeError` (from {@link runOpenCodeSdk}), - * in which case we preserve its `detail`; otherwise we fall back to - * {@link openCodeRuntimeErrorDetail} for unknown causes (defects, etc.). - */ -const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProcessError => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId, - detail: OpenCodeRuntimeError.is(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause), + detail: "OpenCode SDK request failed.", cause, }); @@ -253,7 +227,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) => ({ @@ -413,7 +387,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 })); @@ -430,8 +404,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 ?? @@ -569,7 +543,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); @@ -841,7 +815,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(", ") ?? "", ]), ); @@ -975,20 +949,18 @@ 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, }), ), (subscription) => - Stream.fromAsyncIterable( - subscription.stream, - (cause) => - new OpenCodeRuntimeError({ - operation: "event.subscribe", - detail: 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, @@ -1002,7 +974,7 @@ export function makeOpenCodeAdapter( if (Exit.isFailure(exit)) { yield* emitUnexpectedExit( context, - openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), + OpenCodeRuntime.OpenCodeRuntimeError.detailFromCause(Cause.squash(exit.cause)), ); } }), @@ -1056,7 +1028,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: { @@ -1070,14 +1042,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.", }); @@ -1092,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; }); @@ -1103,7 +1082,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, }), @@ -1185,7 +1164,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, @@ -1195,7 +1174,7 @@ export function makeOpenCodeAdapter( } const text = input.input?.trim(); - const fileParts = toOpenCodeFileParts({ + const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ attachments: input.attachments, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ @@ -1238,7 +1217,7 @@ export function makeOpenCodeAdapter( }); } - yield* runOpenCodeSdk("session.promptAsync", () => + yield* OpenCodeRuntime.runOpenCodeSdk("session.promptAsync", () => context.client.session.promptAsync({ sessionID: context.openCodeSessionId, model: parsedModel, @@ -1292,7 +1271,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) { @@ -1322,10 +1301,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)); }); @@ -1343,10 +1322,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)); }); @@ -1386,7 +1365,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, }), @@ -1412,7 +1391,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, }), @@ -1423,7 +1402,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.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index b0e785512dc..803593327ad 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 { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; -import type { OpenCodeInventory } from "../opencodeRuntime.ts"; +import * as Config from "../../config.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import { checkOpenCodeProviderStatus, OpenCodeProbeError } from "./OpenCodeProvider.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), ); @@ -116,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* () { @@ -212,7 +228,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(), @@ -246,6 +262,28 @@ 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/, + ); + }), + ); + + 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 a8285e960fc..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"; @@ -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"); @@ -32,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(); @@ -52,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); } @@ -63,6 +78,15 @@ function normalizedErrorMessage(cause: unknown): string | undefined { return normalizeProbeMessage(cause.message); } +function openCodeServerDisplayTarget(input: string): string | undefined { + try { + const url = new URL(input); + return `${url.protocol}//${url.host}`; + } catch { + return undefined; + } +} + function formatOpenCodeProbeError(input: { readonly cause: unknown; readonly isExternalServer: boolean; @@ -93,9 +117,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.`, }; } @@ -219,7 +247,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,20 +332,18 @@ 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; 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, @@ -333,7 +361,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({ @@ -367,11 +404,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu args: ["--version"], environment: resolvedEnvironment, }) - .pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - ), + .pipe(Effect.mapError(OpenCodeProbeError.fromCause("probe-version"))), ); if (versionExit._tag === "Failure") { return fallback(Cause.squash(versionExit.cause)); @@ -379,10 +412,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, ); } @@ -425,11 +459,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu : {}), }), ); - }).pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - ), + }).pipe(Effect.mapError(OpenCodeProbeError.fromCause("load-inventory"))), ), ); if (inventoryExit._tag === "Failure") { 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/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/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts deleted file mode 100644 index 23075bd9a06..00000000000 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ /dev/null @@ -1,201 +0,0 @@ -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"; - -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); - -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); -} 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..b1653af368a --- /dev/null +++ b/apps/server/src/provider/ProviderAdapterRegistry.ts @@ -0,0 +1,167 @@ +/** + * 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. + * - `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 { + 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 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>; + + /** Resolve routing metadata for a specific live provider instance. */ + readonly getInstanceInfo: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + + /** + * 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`. Retained for migration-era call sites + * that iterate providers to build UI or metrics. + */ + readonly listProviders: () => Effect.Effect>; + + /** + * 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 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>; + } +>()("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)) { + // Only default instances appear through this legacy view; custom + // instances such as `codex_personal` have no driver-kind identity. + kinds.add(instance.driverKind); + } + } + return Array.from(kinds); + }), + ); + + return ProviderAdapterRegistry.of({ + getByInstance, + 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, + }); +}); + +export const layer = Layer.effect(ProviderAdapterRegistry, make); diff --git a/apps/server/src/provider/Layers/ProviderEventLoggers.ts b/apps/server/src/provider/ProviderEventLoggers.ts similarity index 71% rename from apps/server/src/provider/Layers/ProviderEventLoggers.ts rename to apps/server/src/provider/ProviderEventLoggers.ts index 711aa6e76b6..e3328cc03da 100644 --- a/apps/server/src/provider/Layers/ProviderEventLoggers.ts +++ b/apps/server/src/provider/ProviderEventLoggers.ts @@ -25,39 +25,37 @@ * rather than failing the boot Layer, matching the previous best-effort * behavior of `server.ts`. * - * @module provider/Layers/ProviderEventLoggers + * @module provider/ProviderEventLoggers */ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { ServerConfig } from "../../config.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; - -export interface ProviderEventLoggersShape { - readonly native: EventNdjsonLogger | undefined; - readonly canonical: EventNdjsonLogger | undefined; -} +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 - * (`ProviderEventLoggersLive`); consumers (drivers, `ProviderService`) read + * (`layer`); consumers (drivers, `ProviderService`) read * one tag and pluck the field they need. */ export class ProviderEventLoggers extends Context.Service< ProviderEventLoggers, - ProviderEventLoggersShape ->()("t3/provider/Layers/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: ProviderEventLoggersShape = { +export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { native: undefined, canonical: undefined, }; @@ -67,19 +65,15 @@ export const NoOpProviderEventLoggers: ProviderEventLoggersShape = { * 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 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 88% rename from apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts rename to apps/server/src/provider/ProviderInstanceRegistry.test.ts index dbfa7faffea..f327277a4a3 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: * @@ -36,18 +36,19 @@ import { } from "@t3tools/contracts"; 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 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"; +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 +99,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 +148,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }, }; - const { registry } = yield* makeProviderInstanceRegistry({ + const { registry } = yield* ProviderInstanceRegistry.make({ drivers: [CodexDriver], configMap, }); @@ -208,7 +214,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }, }; - const { registry } = yield* makeProviderInstanceRegistry({ + const { registry } = yield* ProviderInstanceRegistry.make({ drivers: [CodexDriver], configMap, }); @@ -228,27 +234,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 +312,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 79% rename from apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts rename to apps/server/src/provider/ProviderInstanceRegistry.ts index b51dc67793e..5a5dadb9868 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, @@ -48,19 +48,86 @@ 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"; -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. 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 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. + * The payload is `void` because consumers always re-pull `listInstances` + * and `listUnavailable` together. + * + * `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 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>; + } +>()("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"; + { + /** + * 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; + } +>()("t3/provider/ProviderInstanceRegistry/ProviderInstanceRegistryMutator") {} /** * Live registry entry: the materialized `ProviderInstance` + the fresh @@ -93,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; @@ -137,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, @@ -180,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 { @@ -315,9 +386,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 +398,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 +433,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 +466,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 +480,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 +497,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 90% rename from apps/server/src/provider/Layers/ProviderRegistry.test.ts rename to apps/server/src/provider/ProviderRegistry.test.ts index b3ab1145495..152da4359da 100644 --- a/apps/server/src/provider/Layers/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"; @@ -24,31 +25,32 @@ 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"; -import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; -import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; -import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import { + 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 "./ProviderInstanceRegistryHydration.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 { - 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"; + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} 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 +473,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 +504,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 +542,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 +592,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 +654,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 +722,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 +811,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-", }), ), @@ -848,6 +854,105 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + 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"); + 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 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)); + + return yield* ProviderRegistry.ProviderRegistry.pipe( + Effect.flatMap((registry) => registry.getProviders), + Effect.provide(runtimeServices), + ); + }); + + assert.deepStrictEqual(yield* loadProviders("initial"), [cachedProvider]); + assert.deepStrictEqual(yield* loadProviders("live"), [fallbackProvider]); + }), + ); + it.effect("returns the cached provider list when a manual refresh fails", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); @@ -902,10 +1007,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 +1114,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 +1207,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 +1224,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 +1299,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 +1316,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 +1420,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 +1437,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 +1481,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 +1498,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..e471caaa2b4 --- /dev/null +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -0,0 +1,769 @@ +/** + * 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, + 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 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"; +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 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, + { + /** + * 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 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. + */ + readonly refresh: ( + provider?: ProviderDriverKind, + ) => Effect.Effect>; + + /** + * Refresh one configured instance. Unknown ids resolve to the current + * 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 one live provider instance, + * falling back to manual-only capabilities when it is unavailable. + */ + readonly getProviderMaintenanceCapabilitiesForInstance: ( + instanceId: ProviderInstanceId, + provider: ProviderDriverKind, + ) => Effect.Effect; + + /** + * 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; + 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>; + } +>()("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 || snapshot.driver !== source.driverKind) { + return Effect.die( + new ProviderSnapshotCorrelationError({ + sourceInstanceId: source.instanceId, + snapshotInstanceId: snapshot.instanceId, + sourceDriver: source.driverKind, + snapshotDriver: 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, + ...(instance.snapshot.isInitialSnapshot === undefined + ? {} + : { isInitialSnapshot: instance.snapshot.isInitialSnapshot }), + 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 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 + >(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, + ([instanceId, instance]) => + Effect.gen(function* () { + const source = buildSnapshotSource(instance); + const provider = yield* source.getSnapshot; + // 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 && + source.isInitialSnapshot?.(provider) === true + ) { + return; + } + 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, + }); + }), + ); + + // 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 + // 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, + }); + 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 94% rename from apps/server/src/provider/Layers/ProviderService.test.ts rename to apps/server/src/provider/ProviderService.test.ts index ccbbce1759f..47e7f4d9646 100644 --- a/apps/server/src/provider/Layers/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"; @@ -41,23 +42,27 @@ 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"; +import { makeAdapterRegistryMock } from "./testUtils/providerAdapterRegistryMock.ts"; + +// 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)); const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); @@ -284,11 +289,11 @@ 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( - makeProviderServiceLive().pipe( + makeFreshProviderServiceLayer().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -337,9 +342,11 @@ 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( + makeFreshProviderServiceLayer().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -397,8 +404,10 @@ 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 providerLayer = makeProviderServiceLive().pipe( + const directoryLayer = ProviderSessionDirectory.layer.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = makeFreshProviderServiceLayer().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -479,10 +488,10 @@ 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( + const providerLayer = makeFreshProviderServiceLayer().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), @@ -551,8 +560,10 @@ 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 providerLayer = makeProviderServiceLive().pipe( + const directoryLayer = ProviderSessionDirectory.layer.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = makeFreshProviderServiceLayer().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -596,8 +607,10 @@ 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 providerLayer = makeProviderServiceLive({ + const directoryLayer = ProviderSessionDirectory.layer.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = makeFreshProviderServiceLayer({ canonicalEventLogger: { filePath: "memory://provider-canonical-events", write: (event, threadId) => { @@ -656,7 +669,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; @@ -667,7 +682,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), @@ -728,10 +743,10 @@ it.effect( [ProviderDriverKind.make("codex")]: firstCodex.adapter, }); - const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + const firstDirectoryLayer = ProviderSessionDirectory.layer.pipe( Layer.provide(runtimeRepositoryLayer), ); - const firstProviderLayer = makeProviderServiceLive().pipe( + const firstProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -787,10 +802,10 @@ 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( + const secondProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), @@ -1071,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"), @@ -1298,10 +1325,10 @@ 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( + const firstProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -1336,10 +1363,10 @@ 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( + const secondProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), @@ -1404,10 +1431,10 @@ 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( + const firstProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -1437,10 +1464,10 @@ 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( + const secondProviderLayer = makeFreshProviderServiceLayer().pipe( Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), @@ -1783,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; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/ProviderService.ts similarity index 72% rename from apps/server/src/provider/Layers/ProviderService.ts rename to apps/server/src/provider/ProviderService.ts index 2eaaeb8ce3c..906600f4cd7 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/ProviderService.ts @@ -1,13 +1,15 @@ /** - * ProviderServiceLive - Cross-provider orchestration layer. + * 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). + * Uses Effect `Context.Service` for dependency injection and returns typed + * domain errors for validation, session, codex, and checkpoint workflows. * - * @module ProviderServiceLive + * @module ProviderService */ import { ModelSelection, @@ -19,12 +21,13 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, - type ProviderInstanceId, - type ProviderDriverKind, + ProviderDriverKind, + ProviderInstanceId, type ProviderRuntimeEvent, type ProviderSession, + type ProviderTurnStartResult, } from "@t3tools/contracts"; -import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -44,17 +47,141 @@ import { 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"; +} 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"; +import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; +import { + clearAllMcpProviderSessions, + clearMcpProviderSession, + setMcpProviderSession, +} from "../mcp/McpProviderSession.ts"; +import { + issueActiveMcpCredential, + revokeActiveMcpThread, + revokeAllActiveMcpCredentials, +} from "../mcp/McpSessionRegistry.ts"; + +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, + { + /** Start a provider session. */ + 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; + + /** + * 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; + + /** 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. + * + * Fan-out is owned by ProviderService (not by a standalone event-bus service). + */ + readonly streamEvents: Stream.Stream; + } +>()("t3/provider/ProviderService") {} + const isModelSelection = Schema.is(ModelSelection); /** @@ -62,30 +189,15 @@ const isModelSelection = Schema.is(ModelSelection); * from `ProviderEventLoggers`. Production wiring leaves this undefined and * reads the logger off the tag. */ -export interface ProviderServiceLiveOptions { +export interface ProviderServiceOptions { readonly canonicalEventLogger?: EventNdjsonLogger; } -type ProviderServiceMethod = - ProviderService.ProviderService["Service"][Name]; - 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; @@ -172,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 = ( @@ -186,22 +297,23 @@ 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 }; }; -const makeProviderService = Effect.fn("makeProviderService")(function* ( - options?: ProviderServiceLiveOptions, -) { +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 @@ -215,16 +327,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( 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 => @@ -248,12 +358,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( 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 = ( @@ -388,10 +498,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } 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); @@ -411,10 +521,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( .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( @@ -445,10 +555,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( 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); @@ -519,7 +629,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); - const startSession: ProviderServiceMethod<"startSession"> = Effect.fn("startSession")( + const startSession: ProviderService["Service"]["startSession"] = Effect.fn("startSession")( function* (threadId, rawInput) { const parsed = yield* decodeInputOrValidationError({ operation: "ProviderService.startSession", @@ -543,10 +653,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( 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, @@ -554,10 +664,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( 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 = @@ -602,10 +712,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( 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, @@ -642,157 +752,159 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); - 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, + const sendTurn: ProviderService["Service"]["sendTurn"] = Effect.fn("sendTurn")( + function* (rawInput) { + const parsed = yield* decodeInputOrValidationError({ 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, + schema: ProviderSendTurnInput, + payload: rawInput, }); - 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, + const input = { + ...parsed, + attachments: parsed.attachments ?? [], + }; + 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", + }); + } + 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.interruptTurn", + operation: "ProviderService.sendTurn", allowRecovery: true, }); metricProvider = routed.adapter.provider; + metricModel = input.modelSelection?.model; yield* Effect.annotateCurrentSpan({ - "provider.operation": "interrupt-turn", "provider.kind": routed.adapter.provider, - "provider.thread_id": input.threadId, - "provider.turn_id": input.turnId, + ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), }); - yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); - yield* analytics.record("provider.turn.interrupted", { + 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, - outcomeAttributes: () => - providerMetricAttributes(metricProvider, { - operation: "interrupt", + timer: providerTurnDuration, + attributes: () => + providerTurnMetricAttributes({ + provider: metricProvider, + model: metricModel, + extra: { + operation: "send", + }, }), }), ); }, ); - const respondToRequest: ProviderServiceMethod<"respondToRequest"> = Effect.fn("respondToRequest")( + const interruptTurn: ProviderService["Service"]["interruptTurn"] = Effect.fn("interruptTurn")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ - operation: "ProviderService.respondToRequest", - schema: ProviderRespondToRequestInput, + operation: "ProviderService.interruptTurn", + schema: ProviderInterruptTurnInput, payload: rawInput, }); let metricProvider = "unknown"; return yield* Effect.gen(function* () { const routed = yield* resolveRoutableSession({ threadId: input.threadId, - operation: "ProviderService.respondToRequest", + operation: "ProviderService.interruptTurn", allowRecovery: true, }); metricProvider = routed.adapter.provider; yield* Effect.annotateCurrentSpan({ - "provider.operation": "respond-to-request", + "provider.operation": "interrupt-turn", "provider.kind": routed.adapter.provider, "provider.thread_id": input.threadId, - "provider.request_id": input.requestId, + "provider.turn_id": input.turnId, }); - yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); - yield* analytics.record("provider.request.responded", { + yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); + yield* analytics.record("provider.turn.interrupted", { provider: routed.adapter.provider, - decision: input.decision, }); }).pipe( withMetrics({ counter: providerTurnsTotal, outcomeAttributes: () => providerMetricAttributes(metricProvider, { - operation: "approval-response", + operation: "interrupt", }), }), ); }, ); - const respondToUserInput: ProviderServiceMethod<"respondToUserInput"> = Effect.fn( + 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({ @@ -826,7 +938,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); - const stopSession: ProviderServiceMethod<"stopSession"> = Effect.fn("stopSession")( + const stopSession: ProviderService["Service"]["stopSession"] = Effect.fn("stopSession")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ operation: "ProviderService.stopSession", @@ -874,7 +986,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); - const listSessions: ProviderServiceMethod<"listSessions"> = Effect.fn("listSessions")( + const listSessions: ProviderService["Service"]["listSessions"] = Effect.fn("listSessions")( function* () { const currentAdapters = yield* getAdapterEntries; const sessionsByProvider = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => @@ -935,18 +1047,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "ProviderService.listSessions", binding, ); - if (binding.provider !== session.provider) { + if ( + binding.provider !== session.provider || + overrides.providerInstanceId !== session.providerInstanceId + ) { 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}'.`, - ), + new ProviderSessionBindingCorrelationError({ + threadId: session.threadId, + sessionProvider: session.provider, + bindingProvider: binding.provider, + sessionInstanceId: session.providerInstanceId, + bindingInstanceId: overrides.providerInstanceId, + }), ); } if (session.resumeCursor === undefined && binding.resumeCursor !== undefined) { @@ -961,13 +1073,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); - const getCapabilities: ProviderServiceMethod<"getCapabilities"> = (instanceId) => + const getCapabilities: ProviderService["Service"]["getCapabilities"] = (instanceId) => registry.getByInstance(instanceId).pipe(Effect.map((adapter) => adapter.capabilities)); - const getInstanceInfo: ProviderServiceMethod<"getInstanceInfo"> = (instanceId) => + const getInstanceInfo: ProviderService["Service"]["getInstanceInfo"] = (instanceId) => registry.getInstanceInfo(instanceId); - const rollbackConversation: ProviderServiceMethod<"rollbackConversation"> = Effect.fn( + const rollbackConversation: ProviderService["Service"]["rollbackConversation"] = Effect.fn( "rollbackConversation", )(function* (rawInput) { const input = yield* decodeInputOrValidationError({ @@ -1030,8 +1142,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).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* () { @@ -1060,15 +1172,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* Effect.addFinalizer(() => runStopAll().pipe( - Effect.catchCause((cause) => - Effect.logWarning("failed to stop provider service", { - errorTag: causeErrorTag(cause), - }), - ), + Effect.catchCause((cause) => Effect.logWarning("failed to stop provider service", { cause })), ), ); - return { + return ProviderService.of({ startSession, sendTurn, interruptTurn, @@ -1082,17 +1190,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. - get streamEvents(): ProviderServiceMethod<"streamEvents"> { + get streamEvents(): ProviderService["Service"]["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 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 87% rename from apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts rename to apps/server/src/provider/ProviderSessionDirectory.test.ts index 079b7f10ebf..66065501721 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/ProviderSessionDirectory.test.ts @@ -15,16 +15,16 @@ 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 { isProviderSessionNotFoundError } from "./Errors.ts"; +import * as ProviderSessionDirectory from "./ProviderSessionDirectory.ts"; function makeDirectoryLayer(persistenceLayer: Layer.Layer) { 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 +32,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"); @@ -77,9 +77,20 @@ 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; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = ThreadId.make("thread-runtime"); @@ -124,7 +135,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 +209,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 +247,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 +255,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..dc777ce9198 --- /dev/null +++ b/apps/server/src/provider/ProviderSessionDirectory.ts @@ -0,0 +1,283 @@ +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, + ProviderSessionNotFoundError, + ProviderValidationError, +} from "./Errors.ts"; +import * as ProviderSessionRuntime from "../persistence/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 + | ProviderSessionNotFoundError; + +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< + Option.Option, + ProviderSessionDirectoryPersistenceError + >; + readonly listThreadIds: () => Effect.Effect< + ReadonlyArray, + ProviderSessionDirectoryPersistenceError + >; + readonly listBindings: () => Effect.Effect< + ReadonlyArray, + ProviderSessionDirectoryPersistenceError + >; + } +>()("t3/provider/ProviderSessionDirectory") {} + +const decodeProviderDriverKindValue = Schema.decodeUnknownEffect(ProviderDriverKind); + +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: 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, + 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( + (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()), + 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( + (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; + 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( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.upsert:upsert", + detail: "Failed to persist the provider session binding.", + cause, + }), + ), + ); + }); + + const getProvider: ProviderSessionDirectory["Service"]["getProvider"] = (threadId) => + getBinding(threadId).pipe( + Effect.flatMap( + Option.match({ + onSome: (value) => Effect.succeed(value.provider), + onNone: () => + Effect.fail( + new ProviderSessionNotFoundError({ + threadId, + }), + ), + }), + ), + ); + + const listThreadIds: ProviderSessionDirectory["Service"]["listThreadIds"] = () => + repository.list().pipe( + 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( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.listBindings:list", + detail: "Failed to list persisted provider session bindings.", + cause, + }), + ), + 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 76% rename from apps/server/src/provider/Layers/ProviderSessionReaper.ts rename to apps/server/src/provider/ProviderSessionReaper.ts index ca396b40596..1516a525ab4 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/ProviderSessionReaper.ts @@ -1,31 +1,37 @@ +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"; + { + /** Start the background provider session reaper within the provided scope. */ + 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 +109,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 +129,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 deleted file mode 100644 index 5b755c42eed..00000000000 --- a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 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") {} 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 deleted file mode 100644 index b7426b30338..00000000000 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * 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", -) {} diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts deleted file mode 100644 index 4d4cb4fa01a..00000000000 --- a/apps/server/src/provider/Services/ProviderService.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * 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"; - -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", -) {} 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/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/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/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); diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts new file mode 100644 index 00000000000..79bd7c6f6ca --- /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 * as OpenCodeRuntime 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( + OpenCodeRuntime.runOpenCodeSdk("session.get", () => Promise.reject(cause)), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + 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); + 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* OpenCodeRuntime.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, OpenCodeRuntime.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 a83c134d5bd..e877fbc978a 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -11,29 +11,29 @@ 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 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"; 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"; -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"; @@ -50,35 +50,67 @@ 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()), + exitCode: Schema.optionalKey(Schema.Number), + argumentCount: Schema.optionalKey(Schema.Number), + timeoutMs: Schema.optionalKey(Schema.Number), + responseStatus: Schema.optionalKey(Schema.Number), + stdoutBytes: Schema.optionalKey(Schema.Number), + stderrBytes: Schema.optionalKey(Schema.Number), + }, +) { + override get message(): string { + return `OpenCode runtime operation ${this.operation} failed.`; + } + + static fromCause(input: { + readonly operation: string; + readonly detail: string; + readonly cause: unknown; + readonly argumentCount?: number; + }): OpenCodeRuntimeError { + if (isOpenCodeRuntimeError(input.cause)) { + return input.cause; + } + const responseStatus = openCodeResponseStatus(input.cause); + return new OpenCodeRuntimeError({ + operation: input.operation, + detail: input.detail, + cause: input.cause, + ...(input.argumentCount === undefined ? {} : { argumentCount: input.argumentCount }), + ...(responseStatus === undefined ? {} : { responseStatus }), + }); + } -function encodeJsonStringForDiagnostics(input: unknown): string | undefined { - const result = encodeUnknownJsonStringExit(input); - return Exit.isSuccess(result) ? result.value : undefined; + static detailFromCause(cause: unknown): string { + return isOpenCodeRuntimeError(cause) ? cause.detail : "OpenCode request failed."; + } } -export function openCodeRuntimeErrorDetail(cause: unknown): string { - if (OpenCodeRuntimeError.is(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}`; - } +export const isOpenCodeRuntimeError = Schema.is(OpenCodeRuntimeError); + +function openCodeResponseStatus(cause: unknown): number | undefined { + if (isOpenCodeRuntimeError(cause)) { + return cause.responseStatus; + } + if (typeof cause !== "object" || cause === null) { + return undefined; + } + const response = (cause as Readonly>).response; + if (typeof response !== "object" || response === null) { + return undefined; } - return String(cause); + const status = (response as Readonly>).status; + return typeof status === "number" && Number.isInteger(status) ? status : undefined; +} + +function utf8ByteLength(value: string): number { + return encoder.encode(value).byteLength; } export const runOpenCodeSdk = ( @@ -88,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 { @@ -107,47 +143,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")) { @@ -265,24 +304,14 @@ export function toOpenCodeQuestionAnswers( }); } -function ensureRuntimeError( - operation: OpenCodeRuntimeError["operation"], - detail: string, - cause: unknown, -): OpenCodeRuntimeError { - return OpenCodeRuntimeError.is(cause) - ? cause - : 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( @@ -299,7 +328,8 @@ const makeOpenCodeRuntime = 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 { @@ -310,15 +340,18 @@ const makeOpenCodeRuntime = 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, + }), ), ); - 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 @@ -329,13 +362,12 @@ const makeOpenCodeRuntime = 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; @@ -356,13 +388,13 @@ const makeOpenCodeRuntime = 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, + }), ), ); @@ -422,14 +454,11 @@ const makeOpenCodeRuntime = 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"), - cause: { exitCode, stdout, stderr }, + detail: "OpenCode server exited before startup completed.", + exitCode, + argumentCount: args.length, + stdoutBytes: utf8ByteLength(stdout), + stderrBytes: utf8ByteLength(stderr), }), ).pipe(Effect.ignore); }), @@ -451,11 +480,11 @@ const makeOpenCodeRuntime = 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; @@ -463,7 +492,8 @@ const makeOpenCodeRuntime = 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, }); } @@ -476,7 +506,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 +534,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 +569,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/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); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 5ffb69cd5f7..f06c97fedd0 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -15,10 +15,11 @@ 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 { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { makeProviderMaintenanceCapabilities, @@ -177,7 +178,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 +195,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()), ), ), @@ -460,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 f04e97a13ec..4fc6780f2e9 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -9,18 +9,19 @@ 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"; 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"; -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 { 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 +40,84 @@ 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; -}> {} +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", + { + command: Schema.String, + ...platformFailureFields, + cause: Schema.Defect(), + }, +) { + override get message(): string { + 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); + } +} interface VerifiedProviderRefresh { readonly providers: ReadonlyArray; @@ -80,8 +139,9 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR .pipe( Effect.mapError( (cause) => - new ProviderMaintenanceCommandError({ - message: `Failed to run update command ${input.command}: ${cause.message}`, + new ProviderMaintenanceCommandSpawnError({ + command: input.command, + ...platformFailureAttributes(cause), cause, }), ), @@ -104,8 +164,9 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR ).pipe( Effect.mapError( (cause) => - new ProviderMaintenanceCommandError({ - message: cause instanceof Error ? cause.message : "Update command failed to run.", + new ProviderMaintenanceCommandCollectError({ + command: input.command, + ...platformFailureAttributes(cause), cause, }), ), @@ -191,7 +252,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) => @@ -265,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, @@ -277,7 +338,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; @@ -404,6 +465,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ? new ServerProviderUpdateError({ provider, reason: error.reason, + cause: error, }) : error, ), 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/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, }), ), 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/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 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],