diff --git a/.cursor/rules/cursor-cloud.mdc b/.cursor/rules/cursor-cloud.mdc index 98fcb1d5766a95905c03481ca09e6ffb70aaeeea..63493709c7f157b6465b5a5bc3429026c2f668a1 100644 --- a/.cursor/rules/cursor-cloud.mdc +++ b/.cursor/rules/cursor-cloud.mdc @@ -69,4 +69,4 @@ Gradle auto-downloads any additional pinned SDK packages on demand. ## Known flaky tests (unrelated to setup) - `apps/server/src/git/GitManager.test.ts` (cross-repo PR metadata, can time out at 12s). -- `apps/server/src/provider/Layers/ProviderRegistry.test.ts` (codex binaryPath re-probe ordering). +- `apps/server/src/provider/ProviderRegistry.test.ts` (codex binaryPath re-probe ordering). diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index e9aa52c7410d914b5c74586ced31ea53f5d9f17a..bf1b6caf5092f0606e276d4b8bbc6c29f01f3f4c 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -35,8 +35,8 @@ import * as HttpApi from "effect/http-api/HttpApi"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import { HttpRouter, HttpServer } from "effect/http"; import { Rpc, RpcGroup, RpcServer, RpcSerialization } from "effect/rpc"; -import * as SqlitePersistence from "../src/persistence/Layers/Sqlite.ts"; -import * as OrchestrationEventStore from "../src/persistence/Layers/OrchestrationEventStore.ts"; +import * as SqlitePersistence from "../src/persistence/Sqlite.ts"; +import * as OrchestrationEventStore from "../src/persistence/OrchestrationEventStore.ts"; import * as EventStore from "../src/orchestration-v2/EventStore.ts"; import * as EventSink from "../src/orchestration-v2/EventSink.ts"; import * as ProjectionStore from "../src/orchestration-v2/ProjectionStore.ts"; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 340d37a9c2b42f9e88f91dd17719f00be5d9ebe4..3e9121fa2a9fdc918d5c179d8a6f3d72686a5702 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -476,7 +476,7 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( yield* Console.log("Running migrations on the snapshot..."); const executed = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - // Mirror server boot (persistence/Layers/Sqlite.ts). + // Mirror server boot (persistence/Sqlite.ts). yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared; return yield* runMigrations(); }).pipe( diff --git a/apps/server/scripts/record-codex-app-server-replay-fixture.ts b/apps/server/scripts/record-codex-app-server-replay-fixture.ts index e3126d0df6b39697af657de5155f60c20d5d8542..936a7e8b4e06f7c52321ea364c82a3b8fad3f561 100644 --- a/apps/server/scripts/record-codex-app-server-replay-fixture.ts +++ b/apps/server/scripts/record-codex-app-server-replay-fixture.ts @@ -2,7 +2,7 @@ import * as NodeOS from "node:os"; import { CODEX_THREAD_CONFIG } from "../src/orchestration-v2/Adapters/CodexAdapterV2.ts"; import { revertCodexThread } from "../src/provider/CodexThreadRevert.ts"; -import { buildCodexInitializeParams } from "../src/provider/Layers/CodexProvider.ts"; +import { buildCodexInitializeParams } from "../src/provider/CodexProvider.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 8a738960fe140c29f3af82a5883b882126156a07..12ab851aff6ca85fe0e4463550bb559d088733a1 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -10,7 +10,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as PersistenceErrors from "../persistence/Errors.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index e9e6cdd153c2b0792340ea0d8ed159ed2b050198..0dc0697532cafdb54ca27336b277f61ad13c63a3 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -38,7 +38,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 6f20f4169d2856464882c80aa2f8e5fa00af9055..204e61c189d07a34f2667a72200e7a5e6c4183e4 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 9cff461ad78e268fe13fefc52d9d8412466af305..c19d64b3b5a963b0f1f9a81e5314ef21ae97c08a 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -10,7 +10,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; const layerServerConfig = ( diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1a501efe4c338fef5eb04f28ed8e515fe1934353..0ca9fcfc9290e3fb3f43821f2e3c6aee0dc3a58d 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -16,7 +16,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts index db18d5b2b808f1c646b4764308cc813e5e5ba912..a96844271811f0abdb55cce3a401c61bb0de58d9 100644 --- a/apps/server/src/auth/http.test.ts +++ b/apps/server/src/auth/http.test.ts @@ -15,7 +15,7 @@ import * as HttpRouter from "effect/http/HttpRouter"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as AuthHttp from "./http.ts"; diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index b526d7f1824bd53146c7b5f73be8c443fd900368..965787005ae01524fdce9104e4f3b46e4461b5cf 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -31,7 +31,7 @@ import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as ProjectService from "../project/ProjectService.ts"; diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 3016eb01ff8022c12756cbdbd1d25211fbdea2c8..afa37440c79f3344bf9ffc4e3dd179df0355d025 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -24,7 +24,7 @@ import * as HttpApiClient from "effect/http-api/HttpApiClient"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerConfig from "../config.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 23ac9345fb61cc6955ad1986e2370c4aeffc6e13..43eaa08de53a22d435739ac889eb0b4892039917 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -53,9 +53,9 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr import * as ServerConfig from "../config.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; -import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitManager from "./GitManager.ts"; diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index cb22f9a9b4cb8e9af91748f12aed7a093506fa58..4f68f669fe6e0d7a4c58ae2f6424a62ee94336c3 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -66,7 +66,7 @@ import { import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; -import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import { detachStackFrame } from "./detachStackFrame.ts"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index b179a2f45822821cbaac759e0cc10f4e01f82f17..7c6f9d28c9508e13a658438c6d7c4cdb05b5eaa9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -17,7 +17,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "vite-plus/test"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; -import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 91ee42b02a2838975a355ca0d68469dfc41aa2c6..a73e4599db12f56136bade97af9ccfeafc0d3b6a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -20,7 +20,7 @@ import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; -import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 685f1be0faea29f878b34697cefbba05b0b5e4fe..fc91a19203a995d966f1bef451c6875bbfecd360 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -76,7 +76,7 @@ import { } from "../orchestration-v2/SubagentProjection.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; -import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import { type McpInvocationScope, diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index c57af69016b892b30cd4f94ba07e0f83da2118fe..9a935e2dfe82c8ce2cfbd2b43c86b28813c1dedd 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -22,7 +22,7 @@ import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../../project/ProjectService.ts"; -import * as ProviderRegistry from "../../provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; import * as SecretRequests from "../../secrets/SecretRequests.ts"; import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; import * as McpHttpServer from "../McpHttpServer.ts"; diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 503feb2bc66cba0172857f2bc31d211b00975ccd..0522a55da9210c12b4782e3d2d3962b164ba311d 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -17,7 +17,7 @@ import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdap import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; -import * as ProviderRegistry from "../../../provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../../../secrets/SecretRequests.ts"; import * as ServerSettings from "../../../serverSettings.ts"; diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts deleted file mode 100644 index 8be37e82b7950f6264992ade7b296917bad17d28..0000000000000000000000000000000000000000 --- a/apps/server/src/observability/Layers/Observability.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as HttpObservability from "@t3tools/shared/httpObservability"; -import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; -import * as SharedObservability from "@t3tools/shared/observability"; -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as References from "effect/References"; -import * as Tracer from "effect/Tracer"; -import * as OtlpExporter from "effect/observability/OtlpExporter"; -import * as OtlpMetrics from "effect/observability/OtlpMetrics"; -import * as OtlpTracer from "effect/observability/OtlpTracer"; - -import * as ServerConfig from "../../config.ts"; -import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; -import * as ServerLogger from "../../serverLogger.ts"; -import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; - -export const layer = Layer.unwrap( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - - const traces = config.otlpTracesExport; - const metrics = config.otlpMetricsExport; - // The trace serializer stays in the returned context because the browser - // trace forwarder exports on the same signal. - const layerSerialization = SharedObservability.layerOtlpSerialization(traces.protocol); - const resource = ServerConfig.otlpResource(config); - const attribution = yield* ResourceAttribution.ResourceAttribution; - - const layerTraceReferences = Layer.mergeAll( - Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), - Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), - HttpObservability.layer, - ); - - const layerTracer = Layer.unwrap( - Effect.gen(function* () { - const sink = yield* makeTraceSink({ - filePath: config.serverTracePath, - maxBytes: config.traceMaxBytes, - maxFiles: config.traceMaxFiles, - batchWindowMs: config.traceBatchWindowMs, - onFlush: (stats) => - attribution.record({ - component: "server-trace", - operation: "append", - logicalWriteBytes: stats.logicalWriteBytes, - count: stats.count, - durationMs: stats.durationMs, - }), - }); - const delegate = - config.otlpTracesUrl === undefined - ? undefined - : yield* OtlpTracer.make({ - url: config.otlpTracesUrl, - exportInterval: `${traces.exportIntervalMs} millis`, - headers: traces.headers, - resource, - }); - - const tracer = yield* makeLocalFileTracer({ - filePath: config.serverTracePath, - maxBytes: config.traceMaxBytes, - maxFiles: config.traceMaxFiles, - batchWindowMs: config.traceBatchWindowMs, - sink, - ...(delegate ? { delegate } : {}), - }); - - return Layer.mergeAll( - Layer.succeed(Tracer.Tracer, tracer), - BrowserTraceCollector.layer(sink), - ); - }), - ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(layerSerialization)); - - const metricsLayer = - config.otlpMetricsUrl === undefined - ? Layer.empty - : OtlpMetrics.layer({ - url: config.otlpMetricsUrl, - exportInterval: `${metrics.exportIntervalMs} millis`, - headers: metrics.headers, - resource, - }).pipe(Layer.provide(SharedObservability.layerOtlpSerialization(metrics.protocol))); - - // Logged once the server's loggers are installed, so the warnings use them. - const layerOtelWarnings = Layer.effectDiscard( - Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)), - ); - - return layerOtelWarnings.pipe( - Layer.provideMerge( - Layer.mergeAll(ServerLogger.layer, layerTraceReferences, layerTracer, metricsLayer), - ), - Layer.provide( - OtelEnvironment.layerResourceAttributes(config.otelEnvironment.resourceAttributes), - ), - ); - }), -); diff --git a/apps/server/src/observability/Observability.ts b/apps/server/src/observability/Observability.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab078fb121aa64af34fd490ee2ea136da18eb251 --- /dev/null +++ b/apps/server/src/observability/Observability.ts @@ -0,0 +1,102 @@ +import * as HttpObservability from "@t3tools/shared/httpObservability"; +import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import * as SharedObservability from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as References from "effect/References"; +import * as Tracer from "effect/Tracer"; +import * as OtlpExporter from "effect/observability/OtlpExporter"; +import * as OtlpMetrics from "effect/observability/OtlpMetrics"; +import * as OtlpTracer from "effect/observability/OtlpTracer"; + +import * as ServerConfig from "../config.ts"; +import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; +import * as ServerLogger from "../serverLogger.ts"; +import * as BrowserTraceCollector from "./BrowserTraceCollector.ts"; + +export const layer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + + const traces = config.otlpTracesExport; + const metrics = config.otlpMetricsExport; + // The trace serializer stays in the returned context because the browser + // trace forwarder exports on the same signal. + const layerSerialization = SharedObservability.layerOtlpSerialization(traces.protocol); + const resource = ServerConfig.otlpResource(config); + const attribution = yield* ResourceAttribution.ResourceAttribution; + + const layerTraceReferences = Layer.mergeAll( + Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), + Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), + HttpObservability.layer, + ); + + const layerTracer = Layer.unwrap( + Effect.gen(function* () { + const sink = yield* makeTraceSink({ + filePath: config.serverTracePath, + maxBytes: config.traceMaxBytes, + maxFiles: config.traceMaxFiles, + batchWindowMs: config.traceBatchWindowMs, + onFlush: (stats) => + attribution.record({ + component: "server-trace", + operation: "append", + logicalWriteBytes: stats.logicalWriteBytes, + count: stats.count, + durationMs: stats.durationMs, + }), + }); + const delegate = + config.otlpTracesUrl === undefined + ? undefined + : yield* OtlpTracer.make({ + url: config.otlpTracesUrl, + exportInterval: `${traces.exportIntervalMs} millis`, + headers: traces.headers, + resource, + }); + + const tracer = yield* makeLocalFileTracer({ + filePath: config.serverTracePath, + maxBytes: config.traceMaxBytes, + maxFiles: config.traceMaxFiles, + batchWindowMs: config.traceBatchWindowMs, + sink, + ...(delegate ? { delegate } : {}), + }); + + return Layer.mergeAll( + Layer.succeed(Tracer.Tracer, tracer), + BrowserTraceCollector.layer(sink), + ); + }), + ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(layerSerialization)); + + const metricsLayer = + config.otlpMetricsUrl === undefined + ? Layer.empty + : OtlpMetrics.layer({ + url: config.otlpMetricsUrl, + exportInterval: `${metrics.exportIntervalMs} millis`, + headers: metrics.headers, + resource, + }).pipe(Layer.provide(SharedObservability.layerOtlpSerialization(metrics.protocol))); + + // Logged once the server's loggers are installed, so the warnings use them. + const layerOtelWarnings = Layer.effectDiscard( + Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)), + ); + + return layerOtelWarnings.pipe( + Layer.provideMerge( + Layer.mergeAll(ServerLogger.layer, layerTraceReferences, layerTracer, metricsLayer), + ), + Layer.provide( + OtelEnvironment.layerResourceAttributes(config.otelEnvironment.resourceAttributes), + ), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index 87e6afcbfadaca7e7b09b1b115be946b65e0fc82..2eb06f7570529863e79a2c4e4576e07dcda6d787 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -15,7 +15,7 @@ import { import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; +import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; import { FetchHttpClient } from "effect/http"; import { describe } from "vite-plus/test"; @@ -23,13 +23,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; import * as ServerConfig from "../config.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; -import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; -import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; +import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts index 86df1e23322bdfc824a660e332034cc393a4f866..be73eaec681928d3188a66ded3ac07d580a8e6e4 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts @@ -31,7 +31,7 @@ import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts"; import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; -import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; import * as IdAllocator from "../IdAllocator.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 3fcf3f1ddb454085f769c8f9b5c2ff8107cae387..1c6339ffb4aa07d71f42f1b846bfb997de5e8694 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -59,7 +59,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; -import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; +import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Event, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 5e1dbeeb18d5982241599b78edc47766c7b837fe..9aa6163f2adb11f1d1d2fbf6ade530b281113b9b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -106,13 +106,13 @@ import { boundProviderEventForLogging, type EventNdjsonLogger, shouldPersistProviderEvent, -} from "../../provider/Layers/EventNdjsonLogger.ts"; -import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; +} from "../../provider/EventNdjsonLogger.ts"; +import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import { claudeRateLimitEventToUpdate, type ClaudeScopedLimitNames, -} from "../../provider/Layers/claudeUsageLimits.ts"; -import type { ServerProviderShape } from "../../provider/Services/ServerProvider.ts"; +} from "../../provider/claudeUsageLimits.ts"; +import type { ServerProviderShape } from "../../provider/ServerProvider.ts"; import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 4e1b1b801348b96961e2222cd10e0b96a5cfb1c3..34c4b8886427b14bca5861ef1bd765b63052d296 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -51,8 +51,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import packageJson from "../../../package.json" with { type: "json" }; import * as ServerConfig from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; -import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; +import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; +import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import * as IdAllocator from "../IdAllocator.ts"; import * as EffectWorker from "../EffectWorker.ts"; import * as Orchestrator from "../Orchestrator.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 9ff9587ae63bcf8364ac4c3ad287dc2718e655ce..8541a32db17f150400846e2db62a03221ce822f5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -12,15 +12,15 @@ import { completeCodexTurnTokenUsage, type CodexTurnTokenUsageState, } from "../../provider/CodexTurnTokenUsage.ts"; -import type { ServerProviderShape } from "../../provider/Services/ServerProvider.ts"; +import type { ServerProviderShape } from "../../provider/ServerProvider.ts"; import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts"; -import { buildCodexInitializeParams } from "../../provider/Layers/CodexProvider.ts"; +import { buildCodexInitializeParams } from "../../provider/CodexProvider.ts"; import { codexRateLimitsToUpdate, mergeCodexRateLimits, codexUsageLimitResetAt, type CodexRateLimitSnapshot, -} from "../../provider/Layers/codexUsageLimits.ts"; +} from "../../provider/codexUsageLimits.ts"; import { CodexSettings, defaultInstanceIdForDriver, @@ -101,12 +101,9 @@ import { boundProviderEventForLogging, type EventNdjsonLogger, shouldPersistProviderEvent, -} from "../../provider/Layers/EventNdjsonLogger.ts"; -import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; -import { - codexAppServerArgs, - resolveCodexLaunchArgs, -} from "../../provider/Layers/codexLaunchArgs.ts"; +} from "../../provider/EventNdjsonLogger.ts"; +import { ProviderEventLoggers } from "../../provider/ProviderEventLoggers.ts"; +import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts"; import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts index c12e56dbbdcd81a94ad0dd1b429ec845c5657270..5754ec28706ce09a95e978333b7b4aca8197233b 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { vi } from "vite-plus/test"; -import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; const cursorSdkMock = vi.hoisted(() => { diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts b/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts index 664c31184c49b2218488e1014148b17c9c121c26..036a93a0e557f963666f2d375cd400ddc257937c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts @@ -17,8 +17,8 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { Agent, createAgentPlatform } from "../../provider/cursorSdk.ts"; -import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; -import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; +import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; +import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const; export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor"); diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 37b3cdeea413aa3a743b355edcc6498aae0a2a67..59e09b2b44c06f683fff57e77adc98416198c207 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -24,9 +24,9 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import * as ServerConfig from "../../config.ts"; import * as ProjectStore from "../ProjectStore.ts"; -import { buildInitialGrokProviderSnapshot } from "../../provider/Layers/GrokProvider.ts"; +import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts"; import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; -import * as ProviderInstanceRegistry from "../../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; import * as IdAllocator from "../IdAllocator.ts"; import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; import * as RuntimePolicy from "../RuntimePolicy.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index e5fc6f09caa49d18967781610af38e715cd030c9..b446e7ff02df64170a0e78997b7f48720131e0fa 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -56,7 +56,7 @@ import { import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; -import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import * as IdAllocator from "../IdAllocator.ts"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import * as ProviderAdapter from "../ProviderAdapter.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 5ae9368a784e8fe1a0d37fe37c4f50e120dfc727..44632e02c66d56921597c798da83f5e55a3e6757 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -32,7 +32,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../../config.ts"; -import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; +import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts"; import * as IdAllocator from "../IdAllocator.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index f34da2f612fdf08be544f882909f3cc90a1ef755..ea7deb3846442860b3cd289af3c4b65e0e78efca 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -8,7 +8,7 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../../config.ts"; import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; -import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import * as IdAllocator from "../IdAllocator.ts"; import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index fdae326f9238545c9e0bf3cd023e20cdb01aae6c..9d4a3ae969e8c3f10a4e5cbe2961d7ff48697e0d 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -58,8 +58,8 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; -import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; +import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; +import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import { structuralProtocolMethod, summarizeNativeProtocolPayload, diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index 2693cfa917e6e1a5f7a498983b0872038f54e514..31f5b0004701e7a12cde3165b50bad27d348932a 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -21,7 +21,7 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import { VcsProcessTimeoutError } from "@t3tools/contracts"; import * as CheckpointService from "./CheckpointService.ts"; diff --git a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts index 3f7b7a63df910e625692f9fa784a8263553e1c6a..c47c77795f509d170c24fc6a099692dd56e2e0e1 100644 --- a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts @@ -19,7 +19,7 @@ import * as IdAllocator from "./IdAllocator.ts"; import { checkpointRefForScopeOrdinal } from "./CheckpointService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; const layerProjection = Layer.mergeAll( ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), SqlitePersistence.layerMemory, diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index 5dbfb3dd0db0c10eef92465056cfff627a23d6b1..61e07308bfc144771442a1a16178df5391f04102 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -26,7 +26,7 @@ import * as Stream from "effect/Stream"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as EventSink from "./EventSink.ts"; diff --git a/apps/server/src/orchestration-v2/CommandReceiptStore.ts b/apps/server/src/orchestration-v2/CommandReceiptStore.ts index 011a038082435d0e2d0dafef5c32f033c6b042f1..89a016da1f5166ff646a6d5cfc87e5027fc40f2e 100644 --- a/apps/server/src/orchestration-v2/CommandReceiptStore.ts +++ b/apps/server/src/orchestration-v2/CommandReceiptStore.ts @@ -5,9 +5,7 @@ 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 OrchestrationCommandReceiptsLayer from "../persistence/Layers/OrchestrationCommandReceipts.ts"; -import * as OrchestrationCommandReceipts from "../persistence/Services/OrchestrationCommandReceipts.ts"; +import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts"; /** * ERRORS @@ -209,6 +207,6 @@ const layerBase: Layer.Layer< }), ); -export const layer = layerBase.pipe(Layer.provide(OrchestrationCommandReceiptsLayer.layer)); +export const layer = layerBase.pipe(Layer.provide(OrchestrationCommandReceipts.layer)); export const layerFromApplicationReceipts = layerBase; diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index 06ce15e117a5a75d493d1c13a9c5d9c66a21a427..3713c7e6766a904fa4d713a7d7e9c4987dd8a66e 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -13,7 +13,7 @@ import { import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; +import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; import { FetchHttpClient } from "effect/http"; import { describe } from "vite-plus/test"; @@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; import * as ServerConfig from "../config.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; -import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; -import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; +import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 842dd9caef21e9b3fe6955043d13b2554a86e302..6f1819a672b79ccbce24a8edbbc6f1e514818fe5 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -23,11 +23,11 @@ import * as Stream from "effect/Stream"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../config.ts"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 23abd59ea32fb1dd687171300a8e5eefb965f909..d544763d2c77b70756f820d8f6c2e826d174dc0e 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -23,7 +23,7 @@ import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; import { replayAndBufferProjectedLiveEvents } from "./LiveStreamBudget.ts"; -import type { UnsequencedProjectEvent } from "../persistence/Services/OrchestrationEventStore.ts"; +import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts"; import { projectDomainEventForWire } from "./WireProjection.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; diff --git a/apps/server/src/orchestration-v2/EventStore.ts b/apps/server/src/orchestration-v2/EventStore.ts index 484e72f25f3d4be0b5fcc52b531cced0429c00be..41673c4435dcd8cd1621642d389bb13cd1e7c02b 100644 --- a/apps/server/src/orchestration-v2/EventStore.ts +++ b/apps/server/src/orchestration-v2/EventStore.ts @@ -12,9 +12,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import type * as SqlClient from "effect/sql/SqlClient"; - -import * as OrchestrationEventStoreLayer from "../persistence/Layers/OrchestrationEventStore.ts"; -import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; export class EventStoreAppendEventsError extends Schema.TaggedError()( "EventStoreAppendEventsError", @@ -150,7 +148,7 @@ const layerBase: Layer.Layer = layerBase.pipe( - Layer.provide(OrchestrationEventStoreLayer.layer), + Layer.provide(OrchestrationEventStore.layer), ); export const layerFromOrchestrationEventStore = layerBase; diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index f007dcdda4c40754fb53ad06977bdda6c96402bf..894df7c8d36768e899c86f8852a2088d4402ec1b 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -43,7 +43,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as Statement from "effect/sql/Statement"; import { LIVE_STREAM_MAX_ITEMS, LiveStreamBufferError } from "./LiveStreamBudget.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index c873cdfc1c33ef18d282c5bdba69a4c376e62088..3cec0048ca34d65de2803419747ef5bfb9e02d48 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -13,7 +13,7 @@ import { import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; +import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; import { FetchHttpClient } from "effect/http"; import { describe } from "vite-plus/test"; @@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; import * as ServerConfig from "../config.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; -import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; -import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; +import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index adc7373ba7dd650d9f123826a2d6a3cd7a7e64cb..ba442d99bfece4e0c9862e422e1ed7f211f97a4f 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -43,18 +43,18 @@ import { FetchHttpClient, HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { describe } from "vite-plus/test"; -import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; +import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; import * as ServerConfig from "../config.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; -import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; -import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; +import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; import * as OpenCode2Client from "../provider/opencode2/OpenCode2Client.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; @@ -63,7 +63,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import * as IdAllocator from "./IdAllocator.ts"; diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 273646250a6b4c8e17f512ff32012870626e19ac..c68fc0ace5e93512b8c74679864aa4098812a396 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -21,7 +21,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; diff --git a/apps/server/src/orchestration-v2/ProjectCommands.ts b/apps/server/src/orchestration-v2/ProjectCommands.ts index 3de592283072ca2828fd1a65676e7ab33a7823cd..f096a8b3965f94a742ea5ba3793bd8da1178afd0 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.ts @@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import type { UnsequencedProjectEvent } from "../persistence/Services/OrchestrationEventStore.ts"; +import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts"; import type { ProjectRow } from "./ProjectStore.ts"; export interface ProjectCreateCommand { diff --git a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts index a7f76fdd44dce60d901b17067e9f478db213b922..733f9467f10a900746aabaa3125166b06bf2f468 100644 --- a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts +++ b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts @@ -15,11 +15,11 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { runMigrations } from "../persistence/Migrations.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectService from "../project/ProjectService.ts"; -import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; diff --git a/apps/server/src/orchestration-v2/ProjectStore.test.ts b/apps/server/src/orchestration-v2/ProjectStore.test.ts index a020fdacb277a9fa6b2e49f441f8b3928547253c..23884a0ec54f6b84ebaeafd1f9e1f918cd47a6c7 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.test.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectStore from "./ProjectStore.ts"; it.layer(ProjectStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( diff --git a/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts b/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts index 7422042ca0428084fbde094cd6ed9838a8053236..280845fe492c7f0d65d75d588b69a96130fb78d2 100644 --- a/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts @@ -20,7 +20,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index f1ece048e69a7d9f291ba70215a0d622765b56ac..6bde9f596e2ef3fbc11eedcf2943302f4ad34a6c 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -22,7 +22,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 5ea794a2314374a4c0f02776058f8789fd95251e..f626d0c385834605192736b3ef43c2c9adac5095 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -21,7 +21,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as Statement from "effect/sql/Statement"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 2258c3ad96792d026238192d68ceb887b5fec446..692161ad1a5d6ce8bf055556ff854299e6a51810 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -30,7 +30,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import { diff --git a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts index 807e0f91dc279f81f1816f2242a2ee939b5fdb43..f79c974902a4405c42d537c4ebfc1130f40835aa 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts @@ -19,9 +19,9 @@ import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ProviderAuthFlow from "../provider/ProviderAuthFlow.ts"; -import type { ProviderAuthController } from "../provider/Services/ProviderAuthService.ts"; +import type { ProviderAuthController } from "../provider/ProviderAuthService.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import { ProviderAdapterOpenSessionError, type ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; import { ProviderAdapterDriverCreateError, diff --git a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts index 829f846064c184ef311fbd3923254aa0f13f1618..d487aa18c9be025024f9217bad5b2ceb8563612a 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts @@ -13,7 +13,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import { ProviderAdapterDriverCreateError, type AnyProviderAdapterDriver, diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 47a77c4f16c3cf22bc9b1fd875a33191a3d771d0..f87bde97574a62c85672c211cfd42520658cfe06 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -27,7 +27,7 @@ import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as EventSink from "./EventSink.ts"; import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts index 7e460e738d3080a0966585051270a5215cc4e6ee..6d92c82904ccb1519251996d5209eea336e9d544 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts @@ -25,7 +25,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 88925f8b51a3f0b0952ba98ba976652bd60d1432..c0e3c848a8db4a725c8d8c704cfaf58f04ec21c7 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -36,7 +36,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as McpProviderSession from "../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index fdb45f1931794250e96e5939bb4eeeab0eb03b55..06761c13b3fdefe799b61b52a3add9aa44ea2763 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; -import * as ProviderAuthService from "../provider/Services/ProviderAuthService.ts"; +import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "./IdAllocator.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index fa95b5af8272bb64a7f9ce3cfcac35f5e6de9a18..086abc6c58ebc28dea3605a829e70c927dc82cc2 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -23,7 +23,7 @@ import * as Schema from "effect/Schema"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; -import * as ProviderAuthService from "../provider/Services/ProviderAuthService.ts"; +import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as EventSink from "./EventSink.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import { diff --git a/apps/server/src/orchestration-v2/RunCompletionReads.test.ts b/apps/server/src/orchestration-v2/RunCompletionReads.test.ts index 438211c54487ecca06e655615e1e4062cf7cd8fc..5ff08980efcf657456ae51cc940c1fbc93e9c230 100644 --- a/apps/server/src/orchestration-v2/RunCompletionReads.test.ts +++ b/apps/server/src/orchestration-v2/RunCompletionReads.test.ts @@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; const layerDatabase = Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index 59c73d23c95186668e3bd18aa6758543848aa0bf..b4cf0630fb8aa49add2b54d44ad03afc62d4d499 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -15,7 +15,7 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index 0b0523a2c2fb70f38df7e2b2903be7fc291a8e2a..7387de15748fd368092d8157e7dd18f61edeb52f 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 6dc49aefa6c469d0f25764682de22cfcd0679f28..3395c03204604e8e8744aa839025285d1468f5c2 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -40,7 +40,7 @@ import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index d8aff311d77aca6ed9c7cb9e75cd8162ebcda8b5..b4a6a9ce8f420bcad1321ed9e020b743989a1a2c 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -39,7 +39,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; -import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 0a9821754b2d9910e58544c24d653730027055fb..684722111384692aa810bc6d15dd4461d7d25261 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -12,7 +12,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ThreadSearch from "./ThreadSearch.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 0ec3f8e7fbff0515a915b9b468d1b3f8b7d39f1c..60a251d019fa7ca0a4bfba5ebc951b914e8bd3c2 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -19,7 +19,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; import * as Orchestrator from "./Orchestrator.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 3d5efd03fb4a49de4ad61a6349112f3730c9943b..837f08927164f583eb7d3ea73978e2977d716f21 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -16,7 +16,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; diff --git a/apps/server/src/orchestration-v2/TurnStartReads.test.ts b/apps/server/src/orchestration-v2/TurnStartReads.test.ts index 7aedc4e0e09df4f0405e632eb422bd379aaf5cf9..86a3f8cec99a35154bf7518e6116cae1c9ffa75b 100644 --- a/apps/server/src/orchestration-v2/TurnStartReads.test.ts +++ b/apps/server/src/orchestration-v2/TurnStartReads.test.ts @@ -16,7 +16,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; const layerDatabase = Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 26593b5b61e44317dbb69cb800c216ddb1023818..6802a7085b88d84234f1f16ecd93f162b72bc346 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -17,7 +17,7 @@ import { failEnvironmentNotFound, requireEnvironmentScope, } from "../auth/http.ts"; -import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import { buildBoundedThreadProjection, diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts index b4861062ed5346608d7dd63a1a500ab4d522f47d..65d8100d9195dba2da6f8c5b98e86173e6d1c1ce 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts @@ -27,7 +27,7 @@ import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; import { runMigrations } from "../../persistence/Migrations.ts"; -import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import Migration0042 from "../../persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts"; import Migration0043 from "../../persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts"; import Migration0044 from "../../persistence/Migrations/044_ClearAutomaticProjectModelDefaults.ts"; diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts index 07d992e40c33312c41ce0c60aab1c594938bf490..c90448965e77496bcac5ea19111ab277837de6fa 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import { listLinkedPullRequestThreads } from "../../pullRequest/linkedThreads.ts"; import * as EventSink from "../EventSink.ts"; import * as EventStore from "../EventStore.ts"; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index a1a023ffddc56f9202a49994889b33cf2c1be9f1..131f05034051b2b5cfaf53762e8a9eb2521009bd 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -43,13 +43,13 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as ServerConfig from "../config.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; -import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; -import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 56848952cb6941cb636194b5296daa83d3c8b8b7..917ad3a30b8cb04a935d26bb34f3664f5afa598f 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -1,11 +1,11 @@ import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as Layer from "effect/Layer"; -import * as OrchestrationCommandReceipts from "../persistence/Layers/OrchestrationCommandReceipts.ts"; -import * as OrchestrationEventStore from "../persistence/Layers/OrchestrationEventStore.ts"; +import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; -import * as ProviderAuthService from "../provider/Layers/ProviderAuthService.ts"; +import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as AgentSessionImporter from "../project/AgentSessionImporter.ts"; import * as AgentSessionScanner from "../project/AgentSessionScanner.ts"; import * as ProjectService from "../project/ProjectService.ts"; diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index d4749517a22e8c04003e02c36a792117101b0681..21ba303966ecbf92d459c1083224ef0a74916bb7 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -21,7 +21,7 @@ import { } from "../Adapters/CursorAdapterV2.testkit.ts"; import * as CursorAdapterV2Testkit from "../Adapters/CursorAdapterV2.testkit.ts"; import * as IdAllocator from "../IdAllocator.ts"; -import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { CLAUDE_MODEL_SELECTION, diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts index a6555d290b98a2e374b8045f28a27bb36e2b275b..320310a94717e513063c0dca05a065dcb7fe33a5 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts @@ -12,7 +12,7 @@ import { } from "../Adapters/ClaudeAdapterV2.testkit.ts"; import * as IdAllocator from "../IdAllocator.ts"; import * as EffectWorker from "../EffectWorker.ts"; -import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT } from "./fixtures/claude_background_subagent_after_root/input.ts"; import { diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 2206a7787a60199e3ebd9975e2163e72270bb856..ba6dc2d284ea20e99b26ffa501b6bc4a37bac611 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -13,7 +13,7 @@ import type { SqlError } from "effect/sql/SqlError"; import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../../config.ts"; -import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ThreadManagementService from "../ThreadManagementService.ts"; import * as McpSessionRegistryTestkit from "../../mcp/McpSessionRegistry.testkit.ts"; @@ -34,7 +34,7 @@ import * as Orchestrator from "../Orchestrator.ts"; import * as ProjectionStore from "../ProjectionStore.ts"; import * as ProjectStore from "../ProjectStore.ts"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; -import * as ProviderAuthService from "../../provider/Services/ProviderAuthService.ts"; +import * as ProviderAuthService from "../../provider/ProviderAuthService.ts"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import * as ProviderContinuationService from "../ProviderContinuationService.ts"; import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 0a687bb76c672fe6942e0bb231b323ea60ca810d..d55f74ce9c83d5074d8e52659611cb5d56cc0620 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -32,7 +32,7 @@ import * as Stream from "effect/Stream"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts"; import { ClaudeProviderCapabilitiesV2 } from "../Adapters/ClaudeAdapterV2.ts"; import { diff --git a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs index 36d5ce1adb1b8d03d355daf4b762ef97d899b44e..f17d4b5ef48385d5c8333615ec3c8cf7663c2948 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs +++ b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs @@ -25,7 +25,7 @@ const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project app("orchestration-v2/ContextHandoffService"), app("git/GitWorkflowService"), app("project/ProjectService"), - app("provider/Services/ProviderAuthService"), + app("provider/ProviderAuthService"), ]); let current; let fullReads = 0; diff --git a/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts deleted file mode 100644 index 0338e4de50fa4bb1205d6f068bca7ea0332c9248..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts +++ /dev/null @@ -1,126 +0,0 @@ -import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlSchema from "effect/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { toPersistenceSqlError } from "../Errors.ts"; - -import * as OrchestrationCommandReceipts from "../Services/OrchestrationCommandReceipts.ts"; - -const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertReceiptRow = SqlSchema.void({ - Request: OrchestrationCommandReceipts.OrchestrationCommandReceipt, - execute: (receipt) => - sql` - INSERT INTO orchestration_command_receipts ( - command_id, - aggregate_kind, - aggregate_id, - command_type, - accepted_at, - result_sequence, - status, - error - ) - VALUES ( - ${receipt.commandId}, - ${receipt.aggregateKind}, - ${receipt.aggregateId}, - ${receipt.commandType}, - ${receipt.acceptedAt}, - ${receipt.resultSequence}, - ${receipt.status}, - ${receipt.error} - ) - ON CONFLICT (command_id) - DO UPDATE SET - aggregate_kind = excluded.aggregate_kind, - aggregate_id = excluded.aggregate_id, - command_type = excluded.command_type, - accepted_at = excluded.accepted_at, - result_sequence = excluded.result_sequence, - status = excluded.status, - error = excluded.error - `, - }); - - const findReceiptByCommandId = SqlSchema.findOneOption({ - Request: OrchestrationCommandReceipts.GetByCommandIdInput, - Result: OrchestrationCommandReceipts.OrchestrationCommandReceipt, - execute: ({ commandId }) => - sql` - SELECT - command_id AS "commandId", - aggregate_kind AS "aggregateKind", - aggregate_id AS "aggregateId", - command_type AS "commandType", - accepted_at AS "acceptedAt", - result_sequence AS "resultSequence", - status, - error - FROM orchestration_command_receipts - WHERE command_id = ${commandId} - `, - }); - - const upsert: OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape["upsert"] = - (receipt) => - upsertReceiptRow(receipt).pipe( - Effect.mapError( - toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query"), - ), - ); - - const insertIfAbsent: OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape["insertIfAbsent"] = - (receipt) => - sql<{ readonly command_id: string }>` - INSERT INTO orchestration_command_receipts ( - command_id, - aggregate_kind, - aggregate_id, - command_type, - accepted_at, - result_sequence, - status, - error - ) - VALUES ( - ${receipt.commandId}, - ${receipt.aggregateKind}, - ${receipt.aggregateId}, - ${receipt.commandType}, - ${receipt.acceptedAt}, - ${receipt.resultSequence}, - ${receipt.status}, - ${receipt.error} - ) - ON CONFLICT(command_id) DO NOTHING - RETURNING command_id - `.pipe( - Effect.map((rows) => rows.length === 1), - Effect.mapError( - toPersistenceSqlError("OrchestrationCommandReceiptRepository.insertIfAbsent:query"), - ), - ); - - const getByCommandId: OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape["getByCommandId"] = - (input) => - findReceiptByCommandId(input).pipe( - Effect.mapError( - toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"), - ), - ); - - return { - insertIfAbsent, - upsert, - getByCommandId, - } satisfies OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape; -}); - -export const layer = Layer.effect( - OrchestrationCommandReceipts.OrchestrationCommandReceiptRepository, - makeOrchestrationCommandReceiptRepository, -); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.sequence.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.sequence.test.ts deleted file mode 100644 index 6f0eabfa54e4291045b7a85c89d3d243bebcc065..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.sequence.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { CommandId, EventId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; -import { assert, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import * as Tracer from "effect/Tracer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; -import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts"; -import * as OrchestrationEventStoreLayer from "./OrchestrationEventStore.ts"; -import * as SqlitePersistence from "./Sqlite.ts"; - -const layerEventStore = OrchestrationEventStoreLayer.layer.pipe( - Layer.provideMerge(SqlitePersistence.layerMemory), -); -const occurredAt = "2026-09-03T00:00:00.000Z"; -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -function eventRow( - ordinal: number, - aggregateKind: "project" | "thread", - version: number, - streamId = `${aggregateKind}:${ordinal}`, - commandId: string | null = null, -) { - return { - event_id: `event:${ordinal}`, - aggregate_kind: aggregateKind, - stream_id: streamId, - stream_version: ordinal, - event_type: aggregateKind === "project" ? "project.created" : "provider-session.detached", - occurred_at: occurredAt, - command_id: commandId, - causation_event_id: null, - correlation_id: null, - actor_kind: "server", - payload_json: encodeJson( - aggregateKind === "project" - ? { - projectId: streamId, - title: "Sequence fixture", - workspaceRoot: "/tmp/sequence-fixture", - defaultModelSelection: null, - scripts: [], - createdAt: occurredAt, - updatedAt: occurredAt, - } - : { providerSessionId: `session:${ordinal}`, detachedAt: occurredAt }, - ), - metadata_json: "{}", - application_event_version: version, - }; -} - -const seedEvents = Effect.fn("test.seedSequenceEvents")(function* ( - rows: ReadonlyArray>, -) { - const sql = yield* SqlClient.SqlClient; - return yield* sql<{ - readonly sequence: number; - readonly aggregate_kind: string; - readonly application_event_version: number; - readonly stream_id: string; - readonly command_id: string | null; - }>` - INSERT INTO orchestration_events ${sql.insert(rows)} - RETURNING sequence, aggregate_kind, application_event_version, stream_id, command_id - `; -}); - -it.effect("keeps application and scoped agent high-water marks separate from legacy history", () => - Effect.gen(function* () { - const store = yield* OrchestrationEventStore.OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const target = ThreadId.make("target"); - assert.equal(yield* store.latestApplicationSequence, 0); - assert.equal(yield* store.latestAgentSequence(), 0); - assert.equal(yield* store.latestAgentSequence(target), 0); - - yield* seedEvents([eventRow(1, "thread", 1, "legacy-only")]); - assert.equal(yield* store.latestApplicationSequence, 0); - assert.equal(yield* store.latestAgentSequence(ThreadId.make("legacy-only")), 0); - - const [legacyProject, targetEvent, otherEvent] = yield* seedEvents([ - eventRow(2, "project", 1), - eventRow(3, "thread", 2, target), - eventRow(4, "thread", 2, "other"), - ]); - assert.ok(legacyProject); - assert.ok(targetEvent); - assert.ok(otherEvent); - assert.equal(yield* store.latestApplicationSequence, otherEvent.sequence); - assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence); - assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence); - - const [projectEvent] = yield* seedEvents([ - eventRow(5, "project", 2), - eventRow(6, "thread", 1, target), - eventRow(7, "thread", 3, target), - ]); - assert.ok(projectEvent); - assert.equal(yield* store.latestApplicationSequence, projectEvent.sequence); - assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence); - assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence); - assert.equal(yield* store.latestAgentSequence(ThreadId.make("missing")), 0); - - yield* sql`DELETE FROM orchestration_events WHERE sequence > ${legacyProject.sequence}`; - assert.equal(yield* store.latestApplicationSequence, legacyProject.sequence); - assert.equal(yield* store.latestAgentSequence(), 0); - assert.equal(yield* store.latestAgentSequence(target), 0); - }).pipe(Effect.provide(Layer.fresh(layerEventStore))), -); - -it.effect( - "preserves mixed application paging, scoped replay, and the catch-up to live boundary", - () => - Effect.gen(function* () { - const store = yield* OrchestrationEventStore.OrchestrationEventStore; - const rows = yield* seedEvents( - Array.from({ length: 1_560 }, (_, index) => { - const kind = index % 3 === 1 ? "project" : "thread"; - return eventRow( - index, - kind, - index % 3 === 0 ? 1 : 2, - kind === "project" ? `project:${index}` : index % 2 === 0 ? "target" : "other", - index % 5 === 0 ? "selected-command" : "other-command", - ); - }), - ); - const afterSequence = rows[25]!.sequence; - const throughSequence = rows[1_525]!.sequence; - const retained = rows.filter( - (row) => row.aggregate_kind === "project" || row.application_event_version === 2, - ); - const replay = yield* store - .readApplicationEvents({ afterSequence, throughSequence }) - .pipe(Stream.runCollect); - assert.deepEqual( - replay.map((event) => event.sequence), - retained - .filter((row) => row.sequence > afterSequence && row.sequence <= throughSequence) - .map((row) => row.sequence), - ); - - const scoped = yield* store - .readAgentEvents({ - afterSequence, - throughSequence, - threadId: ThreadId.make("target"), - commandId: CommandId.make("selected-command"), - limit: 17, - }) - .pipe(Stream.runCollect); - assert.deepEqual( - scoped.map((event) => event.sequence), - retained - .filter( - (row) => - row.sequence > afterSequence && - row.sequence <= throughSequence && - row.aggregate_kind === "thread" && - row.stream_id === "target" && - row.command_id === "selected-command", - ) - .slice(0, 17) - .map((row) => row.sequence), - ); - - const catchUp = retained.filter((row) => row.sequence > afterSequence); - const highWater = catchUp.at(-1)!.sequence; - let liveSequence = 0; - const streamed = yield* store.streamApplicationEvents({ afterSequence }).pipe( - Stream.tap((event) => - event.sequence !== highWater - ? Effect.void - : Effect.gen(function* () { - const [stored] = yield* store.appendAgentEvents({ - events: [ - { - id: EventId.make("live-after-catch-up"), - type: "provider-session.detached", - threadId: ThreadId.make("target"), - occurredAt: DateTime.makeUnsafe(occurredAt), - payload: { - providerSessionId: ProviderSessionId.make("live-session"), - detachedAt: DateTime.makeUnsafe(occurredAt), - }, - }, - ], - }); - assert.ok(stored); - liveSequence = stored.sequence; - yield* store.publishCommitted([stored]); - }), - ), - Stream.take(catchUp.length + 1), - Stream.runCollect, - ); - assert.deepEqual( - streamed.map((event) => event.sequence), - [...catchUp.map((row) => row.sequence), liveSequence], - ); - assert.isAbove(liveSequence, highWater); - }).pipe(Effect.provide(Layer.fresh(layerEventStore))), -); - -it.effect("uses indexed high-water lookups for populated history without OR scans", () => - Effect.gen(function* () { - const store = yield* OrchestrationEventStore.OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - yield* runMigrations(); - yield* sql` - WITH RECURSIVE history(n) AS ( - SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000 - ) - INSERT INTO orchestration_events ( - event_id, aggregate_kind, stream_id, stream_version, event_type, - occurred_at, actor_kind, payload_json, metadata_json, application_event_version - ) - SELECT 'history:' || n, 'thread', - CASE WHEN n = 1 THEN 'target' ELSE 'other' END, - n, 'provider-session.detached', ${occurredAt}, 'server', '{}', '{}', - CASE WHEN n % 3 = 0 THEN 1 ELSE 2 END - FROM history - `; - const statements: Array = []; - const tracer = Tracer.make({ - span(options) { - const span = new Tracer.NativeSpan(options); - const end = span.end.bind(span); - span.end = (endTime, exit) => { - end(endTime, exit); - const query = span.attributes.get("db.query.text"); - if (typeof query === "string") statements.push(query); - }; - return span; - }, - }); - assert.equal(yield* store.latestApplicationSequence.pipe(Effect.withTracer(tracer)), 25_000); - assert.equal( - yield* store.latestAgentSequence(ThreadId.make("target")).pipe(Effect.withTracer(tracer)), - 1, - ); - assert.equal(statements.length, 2); - const applicationPlan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${statements[0]}`, - ); - const agentPlan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${statements[1]}`, - ["target"], - ); - assert.match( - applicationPlan.map((row) => row.detail).join("\n"), - /SEARCH orchestration_events USING INDEX idx_orchestration_events_application_high_water/, - ); - assert.match( - agentPlan.map((row) => row.detail).join("\n"), - /USING COVERING INDEX idx_orchestration_events_agent_stream_sequence \(stream_id=\?\)/, - ); - for (const row of [...applicationPlan, ...agentPlan]) { - assert.notMatch(row.detail, /MULTI-INDEX OR|TEMP B-TREE/); - } - }).pipe( - Effect.provide( - OrchestrationEventStoreLayer.layer.pipe( - Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), - ), - ), - ), -); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts deleted file mode 100644 index 732f0cdd953613e3b1709113d7222e0d3c7e48da..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -import * as NodeV8 from "node:v8"; - -import { - CommandId, - EventId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnItemId, - type OrchestrationV2DomainEvent, -} from "@t3tools/contracts"; -import { assert, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import * as SqlClient from "effect/sql/SqlClient"; -import * as Statement from "effect/sql/Statement"; - -import { - LIVE_STREAM_MAX_ITEMS, - LiveStreamBufferError, -} from "../../orchestration-v2/LiveStreamBudget.ts"; -import { PersistenceDecodeError } from "../Errors.ts"; -import { toShellApplicationEvent } from "../../orchestration-v2/ShellStream.ts"; -import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts"; -import * as OrchestrationEventStoreLayer from "./OrchestrationEventStore.ts"; -import * as SqlitePersistence from "./Sqlite.ts"; -const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); -const isLiveStreamBufferError = Schema.is(LiveStreamBufferError); - -const layerTest = OrchestrationEventStoreLayer.layer.pipe( - Layer.provideMerge(SqlitePersistence.layerMemory), -); -const layer = it.layer(layerTest); - -layer("OrchestrationEventStore", (it) => { - it.effect("retains only shell metadata from oversized replay and live application events", () => - Effect.scoped( - Effect.gen(function* () { - const store = yield* OrchestrationEventStore.OrchestrationEventStore; - const afterSequence = yield* store.latestApplicationSequence; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread:large-shell-body"); - const input = "x".repeat(9 * 1024 * 1024); - const event: OrchestrationV2DomainEvent = { - id: EventId.make("event:large-shell-body:replay"), - type: "turn-item.updated", - threadId, - occurredAt: now, - payload: { - id: TurnItemId.make("tool:large-shell-body"), - type: "command_execution", - threadId, - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - status: "running", - title: "Large command", - input, - startedAt: now, - completedAt: null, - updatedAt: now, - }, - }; - const [replayed] = yield* store.appendAgentEvents({ events: [event] }); - const pull = yield* Stream.toPull( - store.streamProjectedApplicationEvents({ - afterSequence, - project: toShellApplicationEvent, - }), - ); - assert.deepEqual(yield* pull, [{ sequence: replayed!.sequence, event: { threadId } }]); - const [live] = yield* store.appendAgentEvents({ - events: [ - { - ...event, - id: EventId.make("event:large-shell-body:live"), - payload: { ...event.payload, status: "completed", completedAt: now }, - }, - ], - }); - yield* store.publishCommitted([live!]); - assert.deepEqual(yield* pull, [{ sequence: live!.sequence, event: { threadId } }]); - const durable = yield* store - .readAgentEvents({ threadId, afterSequence }) - .pipe(Stream.runCollect); - assert.lengthOf(durable, 2); - for (const stored of durable) { - assert.equal(stored.event.type, "turn-item.updated"); - if (stored.event.type !== "turn-item.updated") return; - assert.equal(stored.event.payload.type, "command_execution"); - if (stored.event.payload.type !== "command_execution") return; - assert.equal(stored.event.payload.input, input); - } - }), - ), - ); - - it.effect("stores json columns as strings and replays CLI-origin events", () => - Effect.gen(function* () { - const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = "2026-01-01T00:00:00.000Z"; - - const appended = yield* eventStore.appendProjectEvent({ - type: "project.created", - eventId: EventId.make("evt-store-roundtrip"), - aggregateKind: "project", - aggregateId: ProjectId.make("project-roundtrip"), - occurredAt: now, - commandId: CommandId.make("cmd-store-roundtrip"), - causationEventId: null, - correlationId: CommandId.make("cmd-store-roundtrip"), - metadata: { - adapterKey: "codex", - origin: { - surface: "cli", - }, - }, - payload: { - projectId: ProjectId.make("project-roundtrip"), - title: "Roundtrip Project", - workspaceRoot: "/tmp/project-roundtrip", - defaultModelSelection: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - - const storedRows = yield* sql<{ - readonly payloadJson: string; - readonly metadataJson: string; - }>` - SELECT - payload_json AS "payloadJson", - metadata_json AS "metadataJson" - FROM orchestration_events - WHERE event_id = ${appended.eventId} - `; - assert.equal(storedRows.length, 1); - assert.equal(typeof storedRows[0]?.payloadJson, "string"); - assert.equal(typeof storedRows[0]?.metadataJson, "string"); - - const replayed = yield* eventStore - .readApplicationEvents({ - afterSequence: appended.sequence - 1, - throughSequence: appended.sequence, - }) - .pipe( - Stream.runCollect, - Effect.map((chunk) => Array.from(chunk)), - ); - assert.equal(replayed.length, 1); - const [project] = replayed; - if (project === undefined || !("aggregateKind" in project)) { - return assert.fail("Expected a project event"); - } - assert.equal(project.type, "project.created"); - assert.equal(project.metadata.adapterKey, "codex"); - assert.deepEqual(project.metadata.origin, { surface: "cli" }); - }), - ); - - it.effect("fails with PersistenceDecodeError when stored json is invalid", () => - Effect.gen(function* () { - const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = "2026-01-01T00:00:00.000Z"; - - yield* sql` - INSERT INTO orchestration_events ( - event_id, - aggregate_kind, - stream_id, - stream_version, - event_type, - occurred_at, - command_id, - causation_event_id, - correlation_id, - actor_kind, - payload_json, - metadata_json - ) - VALUES ( - ${EventId.make("evt-store-invalid-json")}, - ${"project"}, - ${ProjectId.make("project-invalid-json")}, - ${0}, - ${"project.created"}, - ${now}, - ${CommandId.make("cmd-store-invalid-json")}, - ${null}, - ${null}, - ${"server"}, - ${"{"}, - ${"{}"} - ) - `; - - const replayResult = yield* Effect.result( - Stream.runCollect( - eventStore.readApplicationEvents({ - afterSequence: 0, - throughSequence: Number.MAX_SAFE_INTEGER, - }), - ), - ); - assert.equal(replayResult._tag, "Failure"); - if (replayResult._tag === "Failure") { - assert.ok(isPersistenceDecodeError(replayResult.failure)); - } - }), - ); - - it.effect("orders project and V2 agent events in the retained application event source", () => - Effect.gen(function* () { - const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; - const projectId = ProjectId.make("project-shared-stream"); - const threadId = ThreadId.make("thread-shared-stream"); - const providerInstanceId = ProviderInstanceId.make("codex"); - const occurredAt = DateTime.makeUnsafe("2026-01-02T00:00:00.000Z"); - const now = DateTime.formatIso(occurredAt); - const baselineSequence = yield* eventStore.latestApplicationSequence; - - const projectEvent = yield* eventStore.appendProjectEvent({ - type: "project.created", - eventId: EventId.make("event-project-shared-stream"), - aggregateKind: "project", - aggregateId: projectId, - occurredAt: now, - commandId: CommandId.make("command-project-shared-stream"), - causationEventId: null, - correlationId: CommandId.make("command-project-shared-stream"), - metadata: {}, - payload: { - projectId, - title: "Shared stream", - workspaceRoot: "/tmp/shared-stream", - defaultModelSelection: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - const [threadEvent] = yield* eventStore.appendAgentEvents({ - commandId: CommandId.make("command-thread-shared-stream"), - events: [ - { - id: EventId.make("event-thread-shared-stream"), - type: "thread.created", - threadId, - providerInstanceId, - occurredAt, - payload: { - id: threadId, - projectId, - title: "Thread", - providerInstanceId, - modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { - rootThreadId: threadId, - parentThreadId: null, - relationshipToParent: null, - }, - forkedFrom: null, - createdBy: "user", - creationSource: "web", - createdAt: occurredAt, - updatedAt: occurredAt, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - }, - ], - }); - - const applicationEvents = yield* eventStore - .streamApplicationEvents({ afterSequence: baselineSequence }) - .pipe( - Stream.take(2), - Stream.runCollect, - Effect.map((chunk) => Array.from(chunk)), - ); - assert.deepEqual( - applicationEvents.map((event) => event.sequence), - [projectEvent.sequence, threadEvent!.sequence], - ); - assert.isTrue("aggregateKind" in applicationEvents[0]!); - assert.isTrue("event" in applicationEvents[1]!); - - const finiteReplay = yield* eventStore - .readApplicationEvents({ - afterSequence: baselineSequence, - throughSequence: threadEvent!.sequence, - }) - .pipe( - Stream.runCollect, - Effect.map((chunk) => Array.from(chunk)), - ); - assert.deepEqual( - finiteReplay.map((event) => event.sequence), - [projectEvent.sequence, threadEvent!.sequence], - ); - }), - ); - it.effect("measures only a bounded thread replay before decoding its payloads", () => - Effect.gen(function* () { - const store = yield* OrchestrationEventStore.OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const threadId = ThreadId.make("measured-replay-thread"); - const baseline = yield* store.latestApplicationSequence; - const unicodePayload = '{"text":"🦊"}'; - const oversizedPayload = "{" + "x".repeat(1_048_576); - let streamVersion = 0; - const insert = Effect.fn(function* (input: { - readonly id: string; - readonly payload: string; - readonly type?: string; - readonly threadId?: string; - readonly version?: number; - }) { - const rows = yield* sql<{ readonly sequence: number }>` - INSERT INTO orchestration_events ( - event_id, aggregate_kind, stream_id, stream_version, event_type, - occurred_at, actor_kind, payload_json, metadata_json, application_event_version - ) VALUES ( - ${input.id}, 'thread', ${input.threadId ?? threadId}, - ${streamVersion++}, ${input.type ?? "thread.metadata-updated"}, - '2026-09-04T00:00:00.000Z', 'server', ${input.payload}, '{}', ${input.version ?? 2} - ) RETURNING sequence - `; - return rows[0]!.sequence; - }); - const first = yield* insert({ - id: "measured-replay-1", - payload: unicodePayload, - type: "thread.created", - }); - yield* insert({ - id: "measured-unrelated", - threadId: "other-replay-thread", - payload: oversizedPayload, - }); - yield* insert({ id: "measured-legacy", payload: oversizedPayload, version: 1 }); - const head = yield* insert({ id: "measured-replay-02", payload: oversizedPayload }); - const later = yield* insert({ id: "measured-replay-003", payload: "{}" }); - const range = { threadId, afterSequence: baseline, throughSequence: head, maxEvents: 128 }; - assert.deepEqual(yield* store.getAgentReplayStats(range), { - eventCount: 2, - rawPayloadBytes: Buffer.byteLength(unicodePayload) + Buffer.byteLength(oversizedPayload), - hasCreateEvent: true, - }); - assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: first }), { - eventCount: 1, - rawPayloadBytes: Buffer.byteLength(oversizedPayload), - hasCreateEvent: false, - }); - assert.equal( - (yield* store.getAgentReplayStats({ ...range, throughSequence: later, maxEvents: 1 })) - .eventCount, - 2, - ); - assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: later }), { - eventCount: 0, - rawPayloadBytes: 0, - hasCreateEvent: false, - }); - // The application range skips V1 rows, which a shell resume never replays. - assert.deepEqual( - yield* store.getReplayStats({ afterSequence: baseline, throughSequence: head }), - { - eventCount: 3, - rawPayloadBytes: - Buffer.byteLength(unicodePayload) + 2 * Buffer.byteLength(oversizedPayload), - }, - ); - }), - ); -}); - -it.effect.each(["high-water", "replay"] as const)( - "bounds application live events while the %s query is blocked", - (phase) => - Effect.scoped( - Effect.gen(function* () { - const store = yield* OrchestrationEventStore.OrchestrationEventStore; - const now = "2026-01-03T00:00:00.000Z"; - const projectId = ProjectId.make(`project:blocked-${phase}`); - const projectEvent = yield* store.appendProjectEvent({ - type: "project.created", - eventId: EventId.make(`event:blocked-${phase}:created`), - aggregateKind: "project", - aggregateId: projectId, - occurredAt: now, - commandId: CommandId.make(`command:blocked-${phase}`), - causationEventId: null, - correlationId: null, - metadata: {}, - payload: { - projectId, - title: "Blocked stream", - workspaceRoot: "/tmp/blocked-stream", - defaultModelSelection: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - if (projectEvent.type !== "project.created") - return yield* Effect.die("Expected project.created"); - const readStarted = yield* Deferred.make(); - const readClosed = yield* Deferred.make(); - const blockRead: Statement.Transformer = (statement) => { - const [query] = statement.compile(); - if ( - query.includes("FROM orchestration_events") && - query.includes("MAX(sequence)") === (phase === "high-water") - ) { - return Deferred.succeed(readStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.ensuring(Deferred.succeed(readClosed, undefined)), - Effect.as(statement), - ); - } - return Effect.succeed(statement); - }; - const reader = yield* store - .streamApplicationEvents({ afterSequence: projectEvent.sequence }) - .pipe( - Stream.provideService(Statement.CurrentTransformer, blockRead), - Stream.runDrain, - Effect.result, - Effect.forkScoped, - ); - yield* Deferred.await(readStarted); - yield* store.publishCommitted( - Array.from({ length: LIVE_STREAM_MAX_ITEMS + 1 }, (_, index) => ({ - ...projectEvent, - eventId: EventId.make(`event:blocked-${phase}:${index}`), - sequence: projectEvent.sequence + index + 1, - })), - ); - yield* Deferred.await(readClosed); - const result = yield* Fiber.join(reader); - assert.equal(result._tag, "Failure"); - if (result._tag === "Failure") { - assert.equal(result.failure._tag, "PersistenceSqlError"); - assert.isTrue(isLiveStreamBufferError(result.failure.cause)); - } - }), - ).pipe(Effect.provide(Layer.fresh(layerTest))), -); - -it.effect("releases consumed application replay pages", () => - Effect.gen(function* () { - const store = yield* OrchestrationEventStore.OrchestrationEventStore; - const afterSequence = yield* store.latestApplicationSequence; - yield* Effect.forEach( - Array.from({ length: 1501 }, (_, index) => index), - (index) => - store.appendProjectEvent({ - type: "project.created", - eventId: EventId.make(`retention-${index}`), - aggregateKind: "project", - aggregateId: ProjectId.make(`retention-${index}`), - occurredAt: "2026-09-08T00:00:00Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - payload: { - projectId: ProjectId.make(`retention-${index}`), - title: "Replay", - workspaceRoot: "/tmp/replay", - defaultModelSelection: null, - scripts: [], - createdAt: "2026-09-08T00:00:00Z", - updatedAt: "2026-09-08T00:00:00Z", - }, - }), - { discard: true }, - ); - // oxlint-disable-next-line typescript/no-extraneous-class -- Counts retained page markers after full GC. - class ReplayPage {} - let count = 0; - yield* store - .readApplicationEvents({ - afterSequence, - throughSequence: yield* store.latestApplicationSequence, - }) - .pipe( - Stream.runForEach((event) => - Effect.sync(() => { - if (count % 500 === 0) { - Object.assign(event, { replayPage: new ReplayPage() }); - assert.isAtMost(NodeV8.queryObjects(ReplayPage, { format: "count" }), 1); - } - count++; - }), - ), - ); - assert.equal(count, 1501); - }).pipe(Effect.provide(layerTest)), -); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts deleted file mode 100644 index b1bedcda7e8213da8b8ec6eeb50552ee2abeed71..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ /dev/null @@ -1,609 +0,0 @@ -import { - ApplicationEventMetadata, - ApplicationProjectEvent, - type ApplicationStoredEvent, - CommandId, - EventId, - IsoDateTime, - NonNegativeInt, - OrchestrationV2DomainEventJson, - OrchestrationV2StoredEvent, - ProjectId, - StoredProjectIcon, - ThreadId, - type OrchestrationV2DomainEvent, -} from "@t3tools/contracts"; -import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlSchema from "effect/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as PubSub from "effect/PubSub"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; - -import { replayAndBufferProjectedLiveEvents } from "../../orchestration-v2/LiveStreamBudget.ts"; - -import { - toPersistenceDecodeError, - toPersistenceSqlError, - type OrchestrationEventStoreError, -} from "../Errors.ts"; -import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts"; - -const encodeProjectIcon = Schema.encodeSync(StoredProjectIcon); -const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent); -const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); -const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata); -const ProjectEventType = Schema.Literals([ - "project.created", - "project.meta-updated", - "project.deleted", -]); -const ActorKind = Schema.Literals(["client", "server", "provider"]); - -const AppendProjectEventRequestSchema = Schema.Struct({ - eventId: EventId, - streamId: ProjectId, - type: ProjectEventType, - causationEventId: Schema.NullOr(EventId), - correlationId: Schema.NullOr(CommandId), - actorKind: ActorKind, - occurredAt: IsoDateTime, - commandId: Schema.NullOr(CommandId), - payloadJson: UnknownFromJsonString, - metadataJson: EventMetadataFromJsonString, -}); - -const ProjectEventPersistedRowSchema = Schema.Struct({ - sequence: NonNegativeInt, - eventId: EventId, - type: ProjectEventType, - aggregateKind: Schema.Literal("project"), - aggregateId: ProjectId, - occurredAt: IsoDateTime, - commandId: Schema.NullOr(CommandId), - causationEventId: Schema.NullOr(EventId), - correlationId: Schema.NullOr(CommandId), - payload: UnknownFromJsonString, - metadata: EventMetadataFromJsonString, -}); - -const READ_PAGE_SIZE = 500; - -interface ApplicationEventRow { - readonly sequence: number; - readonly event_id: string; - readonly command_id: string | null; - readonly aggregate_kind: "project" | "thread"; - readonly stream_id: string; - readonly event_type: string; - readonly occurred_at: string; - readonly payload_json: string; - readonly metadata_json: string; - readonly application_event_version: number; - readonly causation_event_id: string | null; - readonly correlation_id: string | null; -} - -const decodeV2EventJson = Schema.decodeUnknownEffect(OrchestrationV2DomainEventJson); -const encodeV2EventJson = Schema.encodeEffect(OrchestrationV2DomainEventJson); -const decodeV2StoredEvent = Schema.decodeUnknownEffect(OrchestrationV2StoredEvent); -const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); -const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); - -function metadataForV2Event(event: OrchestrationV2DomainEvent): Record { - return { - ...(event.runId === undefined ? {} : { runId: event.runId }), - ...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }), - ...(event.driver === undefined ? {} : { driver: event.driver }), - ...(event.providerInstanceId === undefined - ? {} - : { providerInstanceId: event.providerInstanceId }), - ...(event.rawEventId === undefined ? {} : { rawEventId: event.rawEventId }), - }; -} - -const rowToV2StoredEvent = Effect.fn("OrchestrationEventStore.rowToV2StoredEvent")(function* ( - row: ApplicationEventRow, -) { - const payload = yield* decodeJson(row.payload_json); - const metadata = yield* decodeJson(row.metadata_json); - const values = - typeof metadata === "object" && metadata !== null ? (metadata as Record) : {}; - const event = yield* decodeV2EventJson({ - id: row.event_id, - threadId: row.stream_id, - type: row.event_type, - occurredAt: row.occurred_at, - payload, - ...(values.runId === undefined ? {} : { runId: values.runId }), - ...(values.nodeId === undefined ? {} : { nodeId: values.nodeId }), - ...(values.driver === undefined ? {} : { driver: values.driver }), - ...(values.providerInstanceId === undefined - ? {} - : { providerInstanceId: values.providerInstanceId }), - ...(values.rawEventId === undefined ? {} : { rawEventId: values.rawEventId }), - }); - return yield* decodeV2StoredEvent({ - sequence: row.sequence, - commandId: row.command_id, - event, - }); -}); - -const rowToProjectEvent = Effect.fn("OrchestrationEventStore.rowToProjectEvent")(function* ( - row: ApplicationEventRow, -) { - return yield* decodeProjectEvent({ - sequence: row.sequence, - eventId: row.event_id, - type: row.event_type, - aggregateKind: row.aggregate_kind, - aggregateId: row.stream_id, - occurredAt: row.occurred_at, - commandId: row.command_id, - causationEventId: row.causation_event_id, - correlationId: row.correlation_id, - payload: yield* decodeJson(row.payload_json), - metadata: yield* decodeJson(row.metadata_json), - }); -}); - -function rowToApplicationStoredEvent( - row: ApplicationEventRow, -): Effect.Effect { - return row.aggregate_kind === "project" ? rowToProjectEvent(row) : rowToV2StoredEvent(row); -} - -function inferActorKind( - event: OrchestrationEventStore.UnsequencedProjectEvent, -): typeof ActorKind.Type { - if (event.commandId !== null && event.commandId.startsWith("provider:")) { - return "provider"; - } - if (event.commandId !== null && event.commandId.startsWith("server:")) { - return "server"; - } - if ( - event.metadata.providerTurnId !== undefined || - event.metadata.providerItemId !== undefined || - event.metadata.adapterKey !== undefined - ) { - return "provider"; - } - if (event.commandId === null) { - return "server"; - } - return "client"; -} - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown): OrchestrationEventStoreError => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -const makeEventStore = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const committedEvents = yield* PubSub.unbounded(); - - const appendProjectEventRow = SqlSchema.findOne({ - Request: AppendProjectEventRequestSchema, - Result: ProjectEventPersistedRowSchema, - execute: (request) => - sql` - INSERT INTO orchestration_events ( - event_id, - aggregate_kind, - stream_id, - stream_version, - event_type, - occurred_at, - command_id, - causation_event_id, - correlation_id, - actor_kind, - payload_json, - metadata_json, - application_event_version - ) - VALUES ( - ${request.eventId}, - 'project', - ${request.streamId}, - COALESCE( - ( - SELECT stream_version + 1 - FROM orchestration_events - WHERE aggregate_kind = 'project' - AND stream_id = ${request.streamId} - ORDER BY stream_version DESC - LIMIT 1 - ), - 0 - ), - ${request.type}, - ${request.occurredAt}, - ${request.commandId}, - ${request.causationEventId}, - ${request.correlationId}, - ${request.actorKind}, - ${request.payloadJson}, - ${request.metadataJson}, - 2 - ) - RETURNING - sequence, - event_id AS "eventId", - event_type AS "type", - aggregate_kind AS "aggregateKind", - stream_id AS "aggregateId", - occurred_at AS "occurredAt", - command_id AS "commandId", - causation_event_id AS "causationEventId", - correlation_id AS "correlationId", - payload_json AS "payload", - metadata_json AS "metadata" - `, - }); - - const appendProjectEvent: OrchestrationEventStore.OrchestrationEventStoreShape["appendProjectEvent"] = - (event) => - appendProjectEventRow({ - eventId: event.eventId, - streamId: event.aggregateId, - type: event.type, - causationEventId: event.causationEventId, - correlationId: event.correlationId, - actorKind: inferActorKind(event), - occurredAt: event.occurredAt, - commandId: event.commandId, - payloadJson: - event.type === "project.deleted" || !event.payload.projectIcon - ? event.payload - : { ...event.payload, projectIcon: encodeProjectIcon(event.payload.projectIcon) }, - metadataJson: event.metadata, - }).pipe( - Effect.flatMap((row) => decodeProjectEvent(row)), - Effect.mapError( - toPersistenceSqlOrDecodeError( - "OrchestrationEventStore.appendProjectEvent:insert", - "OrchestrationEventStore.appendProjectEvent:decode", - ), - ), - ); - - const readApplicationRows = (input: { - readonly afterSequence: number; - readonly throughSequence?: number; - readonly threadId?: ThreadId; - readonly commandId?: CommandId; - readonly eventType?: OrchestrationV2DomainEvent["type"]; - readonly onlyAgentEvents?: boolean; - readonly limit: number; - }) => - sql` - SELECT - sequence, - event_id, - command_id, - aggregate_kind, - stream_id, - event_type, - occurred_at, - payload_json, - metadata_json, - application_event_version, - causation_event_id, - correlation_id - FROM orchestration_events - ${ - input.onlyAgentEvents === true - ? sql`` - : sql`INDEXED BY idx_orchestration_events_application_high_water` - } - WHERE sequence > ${input.afterSequence} - AND sequence <= ${input.throughSequence ?? Number.MAX_SAFE_INTEGER} - AND ( - ${ - input.onlyAgentEvents === true - ? sql`application_event_version = 2 AND aggregate_kind = 'thread'` - : sql`aggregate_kind = 'project' - OR (application_event_version = 2 AND aggregate_kind = 'thread')` - } - ) - AND ${sql.and([ - ...(input.threadId === undefined ? [] : [sql`stream_id = ${input.threadId}`]), - ...(input.commandId === undefined ? [] : [sql`command_id = ${input.commandId}`]), - ...(input.eventType === undefined ? [] : [sql`event_type = ${input.eventType}`]), - ])} - ORDER BY sequence ASC - LIMIT ${input.limit} - `; - - const appendAgentEvents: OrchestrationEventStore.OrchestrationEventStoreShape["appendAgentEvents"] = - (input) => - Effect.forEach( - input.events, - (event) => - Effect.gen(function* () { - const encoded = yield* encodeV2EventJson(event); - const rows = yield* sql<{ readonly sequence: number }>` - INSERT INTO orchestration_events ( - event_id, - aggregate_kind, - stream_id, - stream_version, - event_type, - occurred_at, - command_id, - causation_event_id, - correlation_id, - actor_kind, - payload_json, - metadata_json, - application_event_version - ) - VALUES ( - ${event.id}, - 'thread', - ${event.threadId}, - COALESCE( - ( - SELECT MAX(stream_version) + 1 - FROM orchestration_events - WHERE aggregate_kind = 'thread' AND stream_id = ${event.threadId} - ), - 0 - ), - ${event.type}, - ${encoded.occurredAt}, - ${input.commandId ?? null}, - NULL, - ${input.commandId ?? null}, - ${event.rawEventId === undefined ? "server" : "provider"}, - ${yield* encodeJson(encoded.payload)}, - ${yield* encodeJson(metadataForV2Event(event))}, - 2 - ) - RETURNING sequence - `; - return yield* decodeV2StoredEvent({ - sequence: rows[0]?.sequence, - commandId: input.commandId ?? null, - event, - }); - }), - { concurrency: 1 }, - ).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "OrchestrationEventStore.appendAgentEvents:insert", - "OrchestrationEventStore.appendAgentEvents:decode", - ), - ), - ); - - const readAgentEvents: OrchestrationEventStore.OrchestrationEventStoreShape["readAgentEvents"] = ( - input, - ) => { - const totalLimit = - input?.limit === undefined ? Number.MAX_SAFE_INTEGER : Math.max(0, Math.floor(input.limit)); - if (totalLimit === 0) { - return Stream.empty; - } - return Stream.paginate( - { cursor: input?.afterSequence ?? 0, remaining: totalLimit }, - ({ cursor, remaining }) => { - const pageLimit = Math.min(remaining, READ_PAGE_SIZE); - return readApplicationRows({ - afterSequence: cursor, - ...(input?.throughSequence === undefined - ? {} - : { throughSequence: input.throughSequence }), - ...(input?.threadId === undefined ? {} : { threadId: input.threadId }), - ...(input?.commandId === undefined ? {} : { commandId: input.commandId }), - ...(input?.eventType === undefined ? {} : { eventType: input.eventType }), - onlyAgentEvents: true, - limit: pageLimit, - }).pipe( - Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.readAgentEvents:query")), - Effect.map((rows) => { - const last = rows.at(-1); - const nextRemaining = remaining - rows.length; - return [ - rows, - last === undefined || - rows.length < pageLimit || - nextRemaining <= 0 || - (input?.throughSequence !== undefined && last.sequence >= input.throughSequence) - ? Option.none() - : Option.some({ cursor: last.sequence, remaining: nextRemaining }), - ] as const; - }), - ); - }, - ).pipe( - Stream.mapEffect((row) => - rowToV2StoredEvent(row).pipe( - Effect.mapError( - toPersistenceDecodeError("OrchestrationEventStore.readAgentEvents:decode"), - ), - ), - ), - ); - }; - - const getAgentReplayStats: OrchestrationEventStore.OrchestrationEventStoreShape["getAgentReplayStats"] = - (input) => - sql<{ - readonly eventCount: number; - readonly rawPayloadBytes: number; - readonly hasCreateEvent: number; - }>` - SELECT - COUNT(*) AS "eventCount", - COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes", - COALESCE(MAX(event_type = 'thread.created'), 0) AS "hasCreateEvent" - FROM ( - SELECT payload_json, event_type - FROM orchestration_events - WHERE aggregate_kind = 'thread' - AND stream_id = ${input.threadId} - AND application_event_version = 2 - AND sequence > ${input.afterSequence} - AND sequence <= ${input.throughSequence} - ORDER BY sequence ASC - LIMIT ${Math.max(0, Math.floor(input.maxEvents)) + 1} - ) - `.pipe( - Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getAgentReplayStats:query")), - Effect.map((rows) => ({ - eventCount: rows[0]?.eventCount ?? 0, - rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0, - hasCreateEvent: (rows[0]?.hasCreateEvent ?? 0) !== 0, - })), - ); - - const getReplayStats: OrchestrationEventStore.OrchestrationEventStoreShape["getReplayStats"] = ( - input, - ) => - sql<{ readonly eventCount: number; readonly rawPayloadBytes: number }>` - SELECT - COUNT(*) AS "eventCount", - COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes" - FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water - WHERE sequence > ${input.afterSequence} - AND sequence <= ${input.throughSequence} - AND ( - aggregate_kind = 'project' - OR (application_event_version = 2 AND aggregate_kind = 'thread') - ) - `.pipe( - Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getReplayStats:query")), - Effect.map((rows) => ({ - eventCount: rows[0]?.eventCount ?? 0, - rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0, - })), - ); - - const latestAgentSequence: OrchestrationEventStore.OrchestrationEventStoreShape["latestAgentSequence"] = - (threadId) => - sql<{ readonly sequence: number | null }>` - SELECT MAX(sequence) AS sequence - FROM orchestration_events - ${ - threadId === undefined - ? sql`` - : sql`INDEXED BY idx_orchestration_events_agent_stream_sequence` - } - WHERE application_event_version = 2 - AND aggregate_kind = 'thread' - ${threadId === undefined ? sql`` : sql`AND stream_id = ${threadId}`} - `.pipe( - Effect.map((rows) => rows[0]?.sequence ?? 0), - Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")), - ); - - // The OR planner otherwise scans every V2 event instead of seeking the final sequence. - const latestApplicationSequence = sql<{ readonly sequence: number | null }>` - SELECT MAX(sequence) AS sequence - FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water - WHERE aggregate_kind = 'project' - OR (application_event_version = 2 AND aggregate_kind = 'thread') - `.pipe( - Effect.map((rows) => rows[0]?.sequence ?? 0), - Effect.mapError( - toPersistenceSqlError("OrchestrationEventStore.latestApplicationSequence:query"), - ), - ); - - const readApplicationEventPage = (input: { - readonly afterSequence: number; - readonly throughSequence: number; - readonly limit: number; - }): Stream.Stream => - Stream.fromEffect( - readApplicationRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("OrchestrationEventStore.readApplicationEvents:query"), - ), - ), - ).pipe( - Stream.flatMap(Stream.fromIterable), - Stream.mapEffect((row) => - rowToApplicationStoredEvent(row).pipe( - Effect.mapError( - toPersistenceDecodeError("OrchestrationEventStore.readApplicationEvents:decode"), - ), - ), - ), - ); - - const catchUpApplicationEvents = (input: { - readonly afterSequence: number; - readonly throughSequence: number; - }): Stream.Stream => { - return Stream.paginate(input.afterSequence, (afterSequence) => - readApplicationEventPage({ - afterSequence, - throughSequence: input.throughSequence, - limit: READ_PAGE_SIZE, - }).pipe( - Stream.runCollect, - Effect.map((events) => { - const last = events.at(-1); - return [ - events, - last === undefined || - events.length < READ_PAGE_SIZE || - last.sequence >= input.throughSequence - ? Option.none() - : Option.some(last.sequence), - ] as const; - }), - ), - ); - }; - - const streamProjectedApplicationEvents: OrchestrationEventStore.OrchestrationEventStoreShape["streamProjectedApplicationEvents"] = - (input) => - replayAndBufferProjectedLiveEvents({ - subscribe: PubSub.subscribe(committedEvents), - latestSequence: latestApplicationSequence, - afterSequence: input.afterSequence ?? 0, - project: input.project, - replay: (throughSequence) => - catchUpApplicationEvents({ - afterSequence: input?.afterSequence ?? 0, - throughSequence, - }), - }).pipe( - Stream.catchTag("LiveStreamBufferError", (cause) => - Stream.fail( - toPersistenceSqlError("OrchestrationEventStore.streamApplicationEvents:buffer")(cause), - ), - ), - ); - - const streamApplicationEvents: OrchestrationEventStore.OrchestrationEventStoreShape["streamApplicationEvents"] = - (input) => streamProjectedApplicationEvents({ ...input, project: (event) => event }); - - return { - appendProjectEvent, - appendAgentEvents, - readAgentEvents, - getAgentReplayStats, - getReplayStats, - latestAgentSequence, - latestApplicationSequence, - readApplicationEvents: catchUpApplicationEvents, - publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid), - streamApplicationEvents, - streamProjectedApplicationEvents, - } satisfies OrchestrationEventStore.OrchestrationEventStoreShape; -}); - -export const layer = Layer.effect(OrchestrationEventStore.OrchestrationEventStore, makeEventStore); diff --git a/apps/server/src/persistence/Layers/Sqlite.test.ts b/apps/server/src/persistence/Layers/Sqlite.test.ts deleted file mode 100644 index 1bdacd0d13d7814905f2e00b736a6191d02becad..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/Sqlite.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { WAL_SIZE_LIMIT_BYTES } from "./Sqlite.ts"; -import * as SqlitePersistence from "./Sqlite.ts"; - -const lockHolderSource = ` -const { DatabaseSync } = require("node:sqlite"); -const db = new DatabaseSync(process.argv[1]); -db.exec("BEGIN IMMEDIATE"); -process.stdout.write("locked\\n"); -setTimeout(() => { - db.exec("COMMIT"); - db.close(); -}, Number(process.argv[2])); -`; - -const spawnWriteLockHolder = (dbPath: string, holdMs: number) => - Effect.promise( - () => - new Promise((resolve, reject) => { - const holder = NodeChildProcess.spawn( - process.execPath, - ["-e", lockHolderSource, dbPath, String(holdMs)], - { stdio: ["ignore", "pipe", "ignore"] }, - ); - holder.stdout.once("data", () => resolve()); - holder.on("error", reject); - holder.on("exit", () => - reject(new Error("lock holder exited before acquiring the write lock")), - ); - }), - ); - -it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-")); - const dbPath = NodePath.join(tempDir, "state.sqlite"); - - return Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`; - yield* spawnWriteLockHolder(dbPath, 300); - yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`; - const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`; - assert.deepEqual([...rows], [{ id: 1 }]); - }).pipe( - Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))), - Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), - ); -}); - -it.effect("shrinks the WAL file back to the size limit after a large write", () => { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-")); - const dbPath = NodePath.join(tempDir, "state.sqlite"); - const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size; - // About 25% more 4 KB rows than the limit holds, in one transaction. - const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000); - - return Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`CREATE TABLE wal_probe(payload BLOB)`; - yield* sql` - WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount}) - INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n - `; - assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES); - - // The auto-checkpoint after the large commit copied every frame into the - // database, so the next commit restarts the WAL and cuts the file back. - yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`; - assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES); - }).pipe( - Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))), - Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), - ); -}); - -it.effect("applies busy_timeout in the shared persistence setup", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`; - assert.equal(rows[0]?.timeout, 5000); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)), -); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts deleted file mode 100644 index 3fc2721d1f500e85ff099b263690d71afa1bcfb2..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as SqlClient from "effect/sql/SqlClient"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; - -import { runMigrations } from "../Migrations.ts"; -import { initializeV2Database } from "../initializeV2Database.ts"; -import * as ServerConfig from "../../config.ts"; - -// Size the -wal file is cut back to on the first commit after a WAL reset. -export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024; - -const layerSetup = Layer.effectDiscard( - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY. - yield* sql`PRAGMA busy_timeout = 5000;`; - yield* sql`PRAGMA foreign_keys = ON;`; - yield* sql`PRAGMA journal_mode = WAL;`; - // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its - // largest size until the last connection closes. - yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); - yield* runMigrations(); - }), -); - -export const layerFromPath = Effect.fn("makeSqlitePersistenceLive")(function* (dbPath: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); - - return Layer.provideMerge( - layerSetup, - NodeSqliteClient.layer({ - filename: dbPath, - spanAttributes: { - "db.name": path.basename(dbPath), - "service.name": "t3code-server", - }, - }), - ); -}, Layer.unwrap); - -export const layerMemory = Layer.provideMerge( - layerSetup, - NodeSqliteClient.layer({ filename: ":memory:" }), -); - -export const layerConfig = Layer.unwrap( - Effect.gen(function* () { - const { dbPath } = yield* ServerConfig.ServerConfig; - yield* initializeV2Database(dbPath); - return layerFromPath(dbPath); - }), -); diff --git a/apps/server/src/persistence/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/OrchestrationCommandReceipts.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f1d092eeb2941ae466e6bccd8d99c5ff650ae5a --- /dev/null +++ b/apps/server/src/persistence/OrchestrationCommandReceipts.ts @@ -0,0 +1,186 @@ +/** + * OrchestrationCommandReceiptRepository - Repository interface for command receipts. + * + * Owns persistence operations for deduplication and status tracking of + * orchestration command handling. + * + * @module OrchestrationCommandReceiptRepository + */ +import { CommandId, IsoDateTime, NonNegativeInt, ProjectId, ThreadId } from "@t3tools/contracts"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as SqlSchema from "effect/sql/SqlSchema"; +import * as Context from "effect/Context"; +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 { + toPersistenceSqlError, + type OrchestrationCommandReceiptRepositoryError, +} from "./Errors.ts"; + +export const OrchestrationCommandReceipt = Schema.Struct({ + commandId: CommandId, + aggregateKind: Schema.Literals(["project", "thread"]), + aggregateId: Schema.Union([ProjectId, ThreadId]), + commandType: Schema.String, + acceptedAt: IsoDateTime, + resultSequence: NonNegativeInt, + status: Schema.Literals(["accepted", "rejected"]), + error: Schema.NullOr(Schema.String), +}); +export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type; + +export const GetByCommandIdInput = Schema.Struct({ + commandId: CommandId, +}); +export type GetByCommandIdInput = typeof GetByCommandIdInput.Type; + +/** + * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence. + */ +export class OrchestrationCommandReceiptRepository extends Context.Service< + OrchestrationCommandReceiptRepository, + { + readonly insertIfAbsent: ( + receipt: OrchestrationCommandReceipt, + ) => Effect.Effect; + + /** + * Insert or replace a command receipt row. + * + * Upserts by `commandId` for idempotent command-result tracking. + */ + readonly upsert: ( + receipt: OrchestrationCommandReceipt, + ) => Effect.Effect; + + /** + * Read a command receipt by command id. + */ + readonly getByCommandId: ( + input: GetByCommandIdInput, + ) => Effect.Effect< + Option.Option, + OrchestrationCommandReceiptRepositoryError + >; + } +>()("t3/persistence/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {} + +const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertReceiptRow = SqlSchema.void({ + Request: OrchestrationCommandReceipt, + execute: (receipt) => + sql` + INSERT INTO orchestration_command_receipts ( + command_id, + aggregate_kind, + aggregate_id, + command_type, + accepted_at, + result_sequence, + status, + error + ) + VALUES ( + ${receipt.commandId}, + ${receipt.aggregateKind}, + ${receipt.aggregateId}, + ${receipt.commandType}, + ${receipt.acceptedAt}, + ${receipt.resultSequence}, + ${receipt.status}, + ${receipt.error} + ) + ON CONFLICT (command_id) + DO UPDATE SET + aggregate_kind = excluded.aggregate_kind, + aggregate_id = excluded.aggregate_id, + command_type = excluded.command_type, + accepted_at = excluded.accepted_at, + result_sequence = excluded.result_sequence, + status = excluded.status, + error = excluded.error + `, + }); + + const findReceiptByCommandId = SqlSchema.findOneOption({ + Request: GetByCommandIdInput, + Result: OrchestrationCommandReceipt, + execute: ({ commandId }) => + sql` + SELECT + command_id AS "commandId", + aggregate_kind AS "aggregateKind", + aggregate_id AS "aggregateId", + command_type AS "commandType", + accepted_at AS "acceptedAt", + result_sequence AS "resultSequence", + status, + error + FROM orchestration_command_receipts + WHERE command_id = ${commandId} + `, + }); + + const upsert: OrchestrationCommandReceiptRepository["Service"]["upsert"] = (receipt) => + upsertReceiptRow(receipt).pipe( + Effect.mapError(toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")), + ); + + const insertIfAbsent: OrchestrationCommandReceiptRepository["Service"]["insertIfAbsent"] = ( + receipt, + ) => + sql<{ readonly command_id: string }>` + INSERT INTO orchestration_command_receipts ( + command_id, + aggregate_kind, + aggregate_id, + command_type, + accepted_at, + result_sequence, + status, + error + ) + VALUES ( + ${receipt.commandId}, + ${receipt.aggregateKind}, + ${receipt.aggregateId}, + ${receipt.commandType}, + ${receipt.acceptedAt}, + ${receipt.resultSequence}, + ${receipt.status}, + ${receipt.error} + ) + ON CONFLICT(command_id) DO NOTHING + RETURNING command_id + `.pipe( + Effect.map((rows) => rows.length === 1), + Effect.mapError( + toPersistenceSqlError("OrchestrationCommandReceiptRepository.insertIfAbsent:query"), + ), + ); + + const getByCommandId: OrchestrationCommandReceiptRepository["Service"]["getByCommandId"] = ( + input, + ) => + findReceiptByCommandId(input).pipe( + Effect.mapError( + toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"), + ), + ); + + return { + insertIfAbsent, + upsert, + getByCommandId, + } satisfies OrchestrationCommandReceiptRepository["Service"]; +}); + +export const layer = Layer.effect( + OrchestrationCommandReceiptRepository, + makeOrchestrationCommandReceiptRepository, +); diff --git a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..82d4838fb27a896924e90bed4a425c8ae4d56792 --- /dev/null +++ b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts @@ -0,0 +1,274 @@ +import { CommandId, EventId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { runMigrations } from "./Migrations.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as OrchestrationEventStore from "./OrchestrationEventStore.ts"; +import * as SqlitePersistence from "./Sqlite.ts"; + +const layerEventStore = OrchestrationEventStore.layer.pipe( + Layer.provideMerge(SqlitePersistence.layerMemory), +); +const occurredAt = "2026-09-03T00:00:00.000Z"; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +function eventRow( + ordinal: number, + aggregateKind: "project" | "thread", + version: number, + streamId = `${aggregateKind}:${ordinal}`, + commandId: string | null = null, +) { + return { + event_id: `event:${ordinal}`, + aggregate_kind: aggregateKind, + stream_id: streamId, + stream_version: ordinal, + event_type: aggregateKind === "project" ? "project.created" : "provider-session.detached", + occurred_at: occurredAt, + command_id: commandId, + causation_event_id: null, + correlation_id: null, + actor_kind: "server", + payload_json: encodeJson( + aggregateKind === "project" + ? { + projectId: streamId, + title: "Sequence fixture", + workspaceRoot: "/tmp/sequence-fixture", + defaultModelSelection: null, + scripts: [], + createdAt: occurredAt, + updatedAt: occurredAt, + } + : { providerSessionId: `session:${ordinal}`, detachedAt: occurredAt }, + ), + metadata_json: "{}", + application_event_version: version, + }; +} + +const seedEvents = Effect.fn("test.seedSequenceEvents")(function* ( + rows: ReadonlyArray>, +) { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ + readonly sequence: number; + readonly aggregate_kind: string; + readonly application_event_version: number; + readonly stream_id: string; + readonly command_id: string | null; + }>` + INSERT INTO orchestration_events ${sql.insert(rows)} + RETURNING sequence, aggregate_kind, application_event_version, stream_id, command_id + `; +}); + +it.effect("keeps application and scoped agent high-water marks separate from legacy history", () => + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const target = ThreadId.make("target"); + assert.equal(yield* store.latestApplicationSequence, 0); + assert.equal(yield* store.latestAgentSequence(), 0); + assert.equal(yield* store.latestAgentSequence(target), 0); + + yield* seedEvents([eventRow(1, "thread", 1, "legacy-only")]); + assert.equal(yield* store.latestApplicationSequence, 0); + assert.equal(yield* store.latestAgentSequence(ThreadId.make("legacy-only")), 0); + + const [legacyProject, targetEvent, otherEvent] = yield* seedEvents([ + eventRow(2, "project", 1), + eventRow(3, "thread", 2, target), + eventRow(4, "thread", 2, "other"), + ]); + assert.ok(legacyProject); + assert.ok(targetEvent); + assert.ok(otherEvent); + assert.equal(yield* store.latestApplicationSequence, otherEvent.sequence); + assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence); + assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence); + + const [projectEvent] = yield* seedEvents([ + eventRow(5, "project", 2), + eventRow(6, "thread", 1, target), + eventRow(7, "thread", 3, target), + ]); + assert.ok(projectEvent); + assert.equal(yield* store.latestApplicationSequence, projectEvent.sequence); + assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence); + assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence); + assert.equal(yield* store.latestAgentSequence(ThreadId.make("missing")), 0); + + yield* sql`DELETE FROM orchestration_events WHERE sequence > ${legacyProject.sequence}`; + assert.equal(yield* store.latestApplicationSequence, legacyProject.sequence); + assert.equal(yield* store.latestAgentSequence(), 0); + assert.equal(yield* store.latestAgentSequence(target), 0); + }).pipe(Effect.provide(Layer.fresh(layerEventStore))), +); + +it.effect( + "preserves mixed application paging, scoped replay, and the catch-up to live boundary", + () => + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const rows = yield* seedEvents( + Array.from({ length: 1_560 }, (_, index) => { + const kind = index % 3 === 1 ? "project" : "thread"; + return eventRow( + index, + kind, + index % 3 === 0 ? 1 : 2, + kind === "project" ? `project:${index}` : index % 2 === 0 ? "target" : "other", + index % 5 === 0 ? "selected-command" : "other-command", + ); + }), + ); + const afterSequence = rows[25]!.sequence; + const throughSequence = rows[1_525]!.sequence; + const retained = rows.filter( + (row) => row.aggregate_kind === "project" || row.application_event_version === 2, + ); + const replay = yield* store + .readApplicationEvents({ afterSequence, throughSequence }) + .pipe(Stream.runCollect); + assert.deepEqual( + replay.map((event) => event.sequence), + retained + .filter((row) => row.sequence > afterSequence && row.sequence <= throughSequence) + .map((row) => row.sequence), + ); + + const scoped = yield* store + .readAgentEvents({ + afterSequence, + throughSequence, + threadId: ThreadId.make("target"), + commandId: CommandId.make("selected-command"), + limit: 17, + }) + .pipe(Stream.runCollect); + assert.deepEqual( + scoped.map((event) => event.sequence), + retained + .filter( + (row) => + row.sequence > afterSequence && + row.sequence <= throughSequence && + row.aggregate_kind === "thread" && + row.stream_id === "target" && + row.command_id === "selected-command", + ) + .slice(0, 17) + .map((row) => row.sequence), + ); + + const catchUp = retained.filter((row) => row.sequence > afterSequence); + const highWater = catchUp.at(-1)!.sequence; + let liveSequence = 0; + const streamed = yield* store.streamApplicationEvents({ afterSequence }).pipe( + Stream.tap((event) => + event.sequence !== highWater + ? Effect.void + : Effect.gen(function* () { + const [stored] = yield* store.appendAgentEvents({ + events: [ + { + id: EventId.make("live-after-catch-up"), + type: "provider-session.detached", + threadId: ThreadId.make("target"), + occurredAt: DateTime.makeUnsafe(occurredAt), + payload: { + providerSessionId: ProviderSessionId.make("live-session"), + detachedAt: DateTime.makeUnsafe(occurredAt), + }, + }, + ], + }); + assert.ok(stored); + liveSequence = stored.sequence; + yield* store.publishCommitted([stored]); + }), + ), + Stream.take(catchUp.length + 1), + Stream.runCollect, + ); + assert.deepEqual( + streamed.map((event) => event.sequence), + [...catchUp.map((row) => row.sequence), liveSequence], + ); + assert.isAbove(liveSequence, highWater); + }).pipe(Effect.provide(Layer.fresh(layerEventStore))), +); + +it.effect("uses indexed high-water lookups for populated history without OR scans", () => + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + yield* sql` + WITH RECURSIVE history(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000 + ) + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, actor_kind, payload_json, metadata_json, application_event_version + ) + SELECT 'history:' || n, 'thread', + CASE WHEN n = 1 THEN 'target' ELSE 'other' END, + n, 'provider-session.detached', ${occurredAt}, 'server', '{}', '{}', + CASE WHEN n % 3 = 0 THEN 1 ELSE 2 END + FROM history + `; + const statements: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + const query = span.attributes.get("db.query.text"); + if (typeof query === "string") statements.push(query); + }; + return span; + }, + }); + assert.equal(yield* store.latestApplicationSequence.pipe(Effect.withTracer(tracer)), 25_000); + assert.equal( + yield* store.latestAgentSequence(ThreadId.make("target")).pipe(Effect.withTracer(tracer)), + 1, + ); + assert.equal(statements.length, 2); + const applicationPlan = yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${statements[0]}`, + ); + const agentPlan = yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${statements[1]}`, + ["target"], + ); + assert.match( + applicationPlan.map((row) => row.detail).join("\n"), + /SEARCH orchestration_events USING INDEX idx_orchestration_events_application_high_water/, + ); + assert.match( + agentPlan.map((row) => row.detail).join("\n"), + /USING COVERING INDEX idx_orchestration_events_agent_stream_sequence \(stream_id=\?\)/, + ); + for (const row of [...applicationPlan, ...agentPlan]) { + assert.notMatch(row.detail, /MULTI-INDEX OR|TEMP B-TREE/); + } + }).pipe( + Effect.provide( + OrchestrationEventStore.layer.pipe( + Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), + ), + ), + ), +); diff --git a/apps/server/src/persistence/OrchestrationEventStore.test.ts b/apps/server/src/persistence/OrchestrationEventStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..abe4b66392d2c17c26fedc4dfa5e4a1dafce14c5 --- /dev/null +++ b/apps/server/src/persistence/OrchestrationEventStore.test.ts @@ -0,0 +1,521 @@ +import * as NodeV8 from "node:v8"; + +import { + CommandId, + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnItemId, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; + +import { + LIVE_STREAM_MAX_ITEMS, + LiveStreamBufferError, +} from "../orchestration-v2/LiveStreamBudget.ts"; +import { PersistenceDecodeError } from "./Errors.ts"; +import { toShellApplicationEvent } from "../orchestration-v2/ShellStream.ts"; +import * as OrchestrationEventStore from "./OrchestrationEventStore.ts"; +import * as SqlitePersistence from "./Sqlite.ts"; +const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); +const isLiveStreamBufferError = Schema.is(LiveStreamBufferError); + +const layerTest = OrchestrationEventStore.layer.pipe( + Layer.provideMerge(SqlitePersistence.layerMemory), +); +const layer = it.layer(layerTest); + +layer("OrchestrationEventStore", (it) => { + it.effect("retains only shell metadata from oversized replay and live application events", () => + Effect.scoped( + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const afterSequence = yield* store.latestApplicationSequence; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:large-shell-body"); + const input = "x".repeat(9 * 1024 * 1024); + const event: OrchestrationV2DomainEvent = { + id: EventId.make("event:large-shell-body:replay"), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make("tool:large-shell-body"), + type: "command_execution", + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: "Large command", + input, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }; + const [replayed] = yield* store.appendAgentEvents({ events: [event] }); + const pull = yield* Stream.toPull( + store.streamProjectedApplicationEvents({ + afterSequence, + project: toShellApplicationEvent, + }), + ); + assert.deepEqual(yield* pull, [{ sequence: replayed!.sequence, event: { threadId } }]); + const [live] = yield* store.appendAgentEvents({ + events: [ + { + ...event, + id: EventId.make("event:large-shell-body:live"), + payload: { ...event.payload, status: "completed", completedAt: now }, + }, + ], + }); + yield* store.publishCommitted([live!]); + assert.deepEqual(yield* pull, [{ sequence: live!.sequence, event: { threadId } }]); + const durable = yield* store + .readAgentEvents({ threadId, afterSequence }) + .pipe(Stream.runCollect); + assert.lengthOf(durable, 2); + for (const stored of durable) { + assert.equal(stored.event.type, "turn-item.updated"); + if (stored.event.type !== "turn-item.updated") return; + assert.equal(stored.event.payload.type, "command_execution"); + if (stored.event.payload.type !== "command_execution") return; + assert.equal(stored.event.payload.input, input); + } + }), + ), + ); + + it.effect("stores json columns as strings and replays CLI-origin events", () => + Effect.gen(function* () { + const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-01-01T00:00:00.000Z"; + + const appended = yield* eventStore.appendProjectEvent({ + type: "project.created", + eventId: EventId.make("evt-store-roundtrip"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-roundtrip"), + occurredAt: now, + commandId: CommandId.make("cmd-store-roundtrip"), + causationEventId: null, + correlationId: CommandId.make("cmd-store-roundtrip"), + metadata: { + adapterKey: "codex", + origin: { + surface: "cli", + }, + }, + payload: { + projectId: ProjectId.make("project-roundtrip"), + title: "Roundtrip Project", + workspaceRoot: "/tmp/project-roundtrip", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const storedRows = yield* sql<{ + readonly payloadJson: string; + readonly metadataJson: string; + }>` + SELECT + payload_json AS "payloadJson", + metadata_json AS "metadataJson" + FROM orchestration_events + WHERE event_id = ${appended.eventId} + `; + assert.equal(storedRows.length, 1); + assert.equal(typeof storedRows[0]?.payloadJson, "string"); + assert.equal(typeof storedRows[0]?.metadataJson, "string"); + + const replayed = yield* eventStore + .readApplicationEvents({ + afterSequence: appended.sequence - 1, + throughSequence: appended.sequence, + }) + .pipe( + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); + assert.equal(replayed.length, 1); + const [project] = replayed; + if (project === undefined || !("aggregateKind" in project)) { + return assert.fail("Expected a project event"); + } + assert.equal(project.type, "project.created"); + assert.equal(project.metadata.adapterKey, "codex"); + assert.deepEqual(project.metadata.origin, { surface: "cli" }); + }), + ); + + it.effect("fails with PersistenceDecodeError when stored json is invalid", () => + Effect.gen(function* () { + const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-01-01T00:00:00.000Z"; + + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + ${EventId.make("evt-store-invalid-json")}, + ${"project"}, + ${ProjectId.make("project-invalid-json")}, + ${0}, + ${"project.created"}, + ${now}, + ${CommandId.make("cmd-store-invalid-json")}, + ${null}, + ${null}, + ${"server"}, + ${"{"}, + ${"{}"} + ) + `; + + const replayResult = yield* Effect.result( + Stream.runCollect( + eventStore.readApplicationEvents({ + afterSequence: 0, + throughSequence: Number.MAX_SAFE_INTEGER, + }), + ), + ); + assert.equal(replayResult._tag, "Failure"); + if (replayResult._tag === "Failure") { + assert.ok(isPersistenceDecodeError(replayResult.failure)); + } + }), + ); + + it.effect("orders project and V2 agent events in the retained application event source", () => + Effect.gen(function* () { + const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; + const projectId = ProjectId.make("project-shared-stream"); + const threadId = ThreadId.make("thread-shared-stream"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const occurredAt = DateTime.makeUnsafe("2026-01-02T00:00:00.000Z"); + const now = DateTime.formatIso(occurredAt); + const baselineSequence = yield* eventStore.latestApplicationSequence; + + const projectEvent = yield* eventStore.appendProjectEvent({ + type: "project.created", + eventId: EventId.make("event-project-shared-stream"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.make("command-project-shared-stream"), + causationEventId: null, + correlationId: CommandId.make("command-project-shared-stream"), + metadata: {}, + payload: { + projectId, + title: "Shared stream", + workspaceRoot: "/tmp/shared-stream", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + const [threadEvent] = yield* eventStore.appendAgentEvents({ + commandId: CommandId.make("command-thread-shared-stream"), + events: [ + { + id: EventId.make("event-thread-shared-stream"), + type: "thread.created", + threadId, + providerInstanceId, + occurredAt, + payload: { + id: threadId, + projectId, + title: "Thread", + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + rootThreadId: threadId, + parentThreadId: null, + relationshipToParent: null, + }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + createdAt: occurredAt, + updatedAt: occurredAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + ], + }); + + const applicationEvents = yield* eventStore + .streamApplicationEvents({ afterSequence: baselineSequence }) + .pipe( + Stream.take(2), + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); + assert.deepEqual( + applicationEvents.map((event) => event.sequence), + [projectEvent.sequence, threadEvent!.sequence], + ); + assert.isTrue("aggregateKind" in applicationEvents[0]!); + assert.isTrue("event" in applicationEvents[1]!); + + const finiteReplay = yield* eventStore + .readApplicationEvents({ + afterSequence: baselineSequence, + throughSequence: threadEvent!.sequence, + }) + .pipe( + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); + assert.deepEqual( + finiteReplay.map((event) => event.sequence), + [projectEvent.sequence, threadEvent!.sequence], + ); + }), + ); + it.effect("measures only a bounded thread replay before decoding its payloads", () => + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("measured-replay-thread"); + const baseline = yield* store.latestApplicationSequence; + const unicodePayload = '{"text":"🦊"}'; + const oversizedPayload = "{" + "x".repeat(1_048_576); + let streamVersion = 0; + const insert = Effect.fn(function* (input: { + readonly id: string; + readonly payload: string; + readonly type?: string; + readonly threadId?: string; + readonly version?: number; + }) { + const rows = yield* sql<{ readonly sequence: number }>` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, actor_kind, payload_json, metadata_json, application_event_version + ) VALUES ( + ${input.id}, 'thread', ${input.threadId ?? threadId}, + ${streamVersion++}, ${input.type ?? "thread.metadata-updated"}, + '2026-09-04T00:00:00.000Z', 'server', ${input.payload}, '{}', ${input.version ?? 2} + ) RETURNING sequence + `; + return rows[0]!.sequence; + }); + const first = yield* insert({ + id: "measured-replay-1", + payload: unicodePayload, + type: "thread.created", + }); + yield* insert({ + id: "measured-unrelated", + threadId: "other-replay-thread", + payload: oversizedPayload, + }); + yield* insert({ id: "measured-legacy", payload: oversizedPayload, version: 1 }); + const head = yield* insert({ id: "measured-replay-02", payload: oversizedPayload }); + const later = yield* insert({ id: "measured-replay-003", payload: "{}" }); + const range = { threadId, afterSequence: baseline, throughSequence: head, maxEvents: 128 }; + assert.deepEqual(yield* store.getAgentReplayStats(range), { + eventCount: 2, + rawPayloadBytes: Buffer.byteLength(unicodePayload) + Buffer.byteLength(oversizedPayload), + hasCreateEvent: true, + }); + assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: first }), { + eventCount: 1, + rawPayloadBytes: Buffer.byteLength(oversizedPayload), + hasCreateEvent: false, + }); + assert.equal( + (yield* store.getAgentReplayStats({ ...range, throughSequence: later, maxEvents: 1 })) + .eventCount, + 2, + ); + assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: later }), { + eventCount: 0, + rawPayloadBytes: 0, + hasCreateEvent: false, + }); + // The application range skips V1 rows, which a shell resume never replays. + assert.deepEqual( + yield* store.getReplayStats({ afterSequence: baseline, throughSequence: head }), + { + eventCount: 3, + rawPayloadBytes: + Buffer.byteLength(unicodePayload) + 2 * Buffer.byteLength(oversizedPayload), + }, + ); + }), + ); +}); + +it.effect.each(["high-water", "replay"] as const)( + "bounds application live events while the %s query is blocked", + (phase) => + Effect.scoped( + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const now = "2026-01-03T00:00:00.000Z"; + const projectId = ProjectId.make(`project:blocked-${phase}`); + const projectEvent = yield* store.appendProjectEvent({ + type: "project.created", + eventId: EventId.make(`event:blocked-${phase}:created`), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.make(`command:blocked-${phase}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + projectId, + title: "Blocked stream", + workspaceRoot: "/tmp/blocked-stream", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + if (projectEvent.type !== "project.created") + return yield* Effect.die("Expected project.created"); + const readStarted = yield* Deferred.make(); + const readClosed = yield* Deferred.make(); + const blockRead: Statement.Transformer = (statement) => { + const [query] = statement.compile(); + if ( + query.includes("FROM orchestration_events") && + query.includes("MAX(sequence)") === (phase === "high-water") + ) { + return Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(readClosed, undefined)), + Effect.as(statement), + ); + } + return Effect.succeed(statement); + }; + const reader = yield* store + .streamApplicationEvents({ afterSequence: projectEvent.sequence }) + .pipe( + Stream.provideService(Statement.CurrentTransformer, blockRead), + Stream.runDrain, + Effect.result, + Effect.forkScoped, + ); + yield* Deferred.await(readStarted); + yield* store.publishCommitted( + Array.from({ length: LIVE_STREAM_MAX_ITEMS + 1 }, (_, index) => ({ + ...projectEvent, + eventId: EventId.make(`event:blocked-${phase}:${index}`), + sequence: projectEvent.sequence + index + 1, + })), + ); + yield* Deferred.await(readClosed); + const result = yield* Fiber.join(reader); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "PersistenceSqlError"); + assert.isTrue(isLiveStreamBufferError(result.failure.cause)); + } + }), + ).pipe(Effect.provide(Layer.fresh(layerTest))), +); + +it.effect("releases consumed application replay pages", () => + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const afterSequence = yield* store.latestApplicationSequence; + yield* Effect.forEach( + Array.from({ length: 1501 }, (_, index) => index), + (index) => + store.appendProjectEvent({ + type: "project.created", + eventId: EventId.make(`retention-${index}`), + aggregateKind: "project", + aggregateId: ProjectId.make(`retention-${index}`), + occurredAt: "2026-09-08T00:00:00Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + projectId: ProjectId.make(`retention-${index}`), + title: "Replay", + workspaceRoot: "/tmp/replay", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-08T00:00:00Z", + updatedAt: "2026-09-08T00:00:00Z", + }, + }), + { discard: true }, + ); + // oxlint-disable-next-line typescript/no-extraneous-class -- Counts retained page markers after full GC. + class ReplayPage {} + let count = 0; + yield* store + .readApplicationEvents({ + afterSequence, + throughSequence: yield* store.latestApplicationSequence, + }) + .pipe( + Stream.runForEach((event) => + Effect.sync(() => { + if (count % 500 === 0) { + Object.assign(event, { replayPage: new ReplayPage() }); + assert.isAtMost(NodeV8.queryObjects(ReplayPage, { format: "count" }), 1); + } + count++; + }), + ), + ); + assert.equal(count, 1501); + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..588defc0d6cabd21fc6ba3e85d3c0f0787ec5203 --- /dev/null +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -0,0 +1,712 @@ +/** + * Historical name for the shared application event store. + * + * Owns durable append/replay access for project events and V2 agent-thread + * events under one global sequence. It does not reduce events into read models + * or apply command validation rules. + * + * Uses Effect `Context.Service` for dependency injection and exposes typed + * persistence/decode errors for event append and replay operations. + * + * @module OrchestrationEventStore + */ +import { + ApplicationEventMetadata, + ApplicationProjectEvent, + type ApplicationStoredEvent, + CommandId, + EventId, + IsoDateTime, + NonNegativeInt, + OrchestrationV2DomainEventJson, + OrchestrationV2StoredEvent, + ProjectId, + StoredProjectIcon, + ThreadId, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as SqlSchema from "effect/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { replayAndBufferProjectedLiveEvents } from "../orchestration-v2/LiveStreamBudget.ts"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type OrchestrationEventStoreError, +} from "./Errors.ts"; + +/** A project event before the store assigns its sequence. */ +export type UnsequencedProjectEvent = ApplicationProjectEvent extends infer Event + ? Event extends ApplicationProjectEvent + ? Omit + : never + : never; + +/** OrchestrationEventStore - Service tag for the shared application event log. */ +export class OrchestrationEventStore extends Context.Service< + OrchestrationEventStore, + { + /** Append one project event to the shared application log. */ + readonly appendProjectEvent: ( + event: UnsequencedProjectEvent, + ) => Effect.Effect; + + /** Append V2 agent events to the same globally ordered application log. */ + readonly appendAgentEvents: (input: { + readonly commandId?: CommandId; + readonly events: ReadonlyArray; + }) => Effect.Effect, OrchestrationEventStoreError>; + + /** + * Read only V2 thread events from the application log. + * + * Reads in fixed-size sequence pages until the filtered range is exhausted; + * `limit` caps the total emitted events across pages. + */ + readonly readAgentEvents: (input?: { + readonly afterSequence?: number; + readonly throughSequence?: number; + readonly threadId?: ThreadId; + readonly commandId?: CommandId; + readonly eventType?: OrchestrationV2DomainEvent["type"]; + readonly limit?: number; + }) => Stream.Stream; + + /** Measure one thread's bounded replay without loading or decoding its payloads. */ + readonly getAgentReplayStats: (input: { + readonly threadId: ThreadId; + readonly afterSequence: number; + readonly throughSequence: number; + readonly maxEvents: number; + }) => Effect.Effect< + { + readonly eventCount: number; + /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */ + readonly rawPayloadBytes: number; + readonly hasCreateEvent: boolean; + }, + OrchestrationEventStoreError + >; + + /** + * Measure the retained application-event range `(afterSequence, throughSequence]` + * without loading or decoding its payloads. + */ + readonly getReplayStats: (input: { + readonly afterSequence: number; + readonly throughSequence: number; + }) => Effect.Effect< + { + readonly eventCount: number; + /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */ + readonly rawPayloadBytes: number; + }, + OrchestrationEventStoreError + >; + + readonly latestAgentSequence: ( + threadId?: ThreadId, + ) => Effect.Effect; + + readonly latestApplicationSequence: Effect.Effect; + + /** Read the finite retained application-event range `(afterSequence, throughSequence]`. */ + readonly readApplicationEvents: (input: { + readonly afterSequence: number; + readonly throughSequence: number; + }) => Stream.Stream; + + /** Publish only after the surrounding event/projection transaction commits. */ + readonly publishCommitted: ( + events: ReadonlyArray, + ) => Effect.Effect; + + /** Race-free replay-to-live stream for project and V2 thread events. */ + readonly streamApplicationEvents: (input?: { + readonly afterSequence?: number; + }) => Stream.Stream; + /** Project transport events before bounding replay and the live tail. */ + readonly streamProjectedApplicationEvents: (input: { + readonly afterSequence?: number; + readonly project: (event: ApplicationStoredEvent) => A; + }) => Stream.Stream; + } +>()("t3/persistence/OrchestrationEventStore") {} + +const encodeProjectIcon = Schema.encodeSync(StoredProjectIcon); +const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent); +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata); +const ProjectEventType = Schema.Literals([ + "project.created", + "project.meta-updated", + "project.deleted", +]); +const ActorKind = Schema.Literals(["client", "server", "provider"]); + +const AppendProjectEventRequestSchema = Schema.Struct({ + eventId: EventId, + streamId: ProjectId, + type: ProjectEventType, + causationEventId: Schema.NullOr(EventId), + correlationId: Schema.NullOr(CommandId), + actorKind: ActorKind, + occurredAt: IsoDateTime, + commandId: Schema.NullOr(CommandId), + payloadJson: UnknownFromJsonString, + metadataJson: EventMetadataFromJsonString, +}); + +const ProjectEventPersistedRowSchema = Schema.Struct({ + sequence: NonNegativeInt, + eventId: EventId, + type: ProjectEventType, + aggregateKind: Schema.Literal("project"), + aggregateId: ProjectId, + occurredAt: IsoDateTime, + commandId: Schema.NullOr(CommandId), + causationEventId: Schema.NullOr(EventId), + correlationId: Schema.NullOr(CommandId), + payload: UnknownFromJsonString, + metadata: EventMetadataFromJsonString, +}); + +const READ_PAGE_SIZE = 500; + +interface ApplicationEventRow { + readonly sequence: number; + readonly event_id: string; + readonly command_id: string | null; + readonly aggregate_kind: "project" | "thread"; + readonly stream_id: string; + readonly event_type: string; + readonly occurred_at: string; + readonly payload_json: string; + readonly metadata_json: string; + readonly application_event_version: number; + readonly causation_event_id: string | null; + readonly correlation_id: string | null; +} + +const decodeV2EventJson = Schema.decodeUnknownEffect(OrchestrationV2DomainEventJson); +const encodeV2EventJson = Schema.encodeEffect(OrchestrationV2DomainEventJson); +const decodeV2StoredEvent = Schema.decodeUnknownEffect(OrchestrationV2StoredEvent); +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +function metadataForV2Event(event: OrchestrationV2DomainEvent): Record { + return { + ...(event.runId === undefined ? {} : { runId: event.runId }), + ...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }), + ...(event.driver === undefined ? {} : { driver: event.driver }), + ...(event.providerInstanceId === undefined + ? {} + : { providerInstanceId: event.providerInstanceId }), + ...(event.rawEventId === undefined ? {} : { rawEventId: event.rawEventId }), + }; +} + +const rowToV2StoredEvent = Effect.fn("OrchestrationEventStore.rowToV2StoredEvent")(function* ( + row: ApplicationEventRow, +) { + const payload = yield* decodeJson(row.payload_json); + const metadata = yield* decodeJson(row.metadata_json); + const values = + typeof metadata === "object" && metadata !== null ? (metadata as Record) : {}; + const event = yield* decodeV2EventJson({ + id: row.event_id, + threadId: row.stream_id, + type: row.event_type, + occurredAt: row.occurred_at, + payload, + ...(values.runId === undefined ? {} : { runId: values.runId }), + ...(values.nodeId === undefined ? {} : { nodeId: values.nodeId }), + ...(values.driver === undefined ? {} : { driver: values.driver }), + ...(values.providerInstanceId === undefined + ? {} + : { providerInstanceId: values.providerInstanceId }), + ...(values.rawEventId === undefined ? {} : { rawEventId: values.rawEventId }), + }); + return yield* decodeV2StoredEvent({ + sequence: row.sequence, + commandId: row.command_id, + event, + }); +}); + +const rowToProjectEvent = Effect.fn("OrchestrationEventStore.rowToProjectEvent")(function* ( + row: ApplicationEventRow, +) { + return yield* decodeProjectEvent({ + sequence: row.sequence, + eventId: row.event_id, + type: row.event_type, + aggregateKind: row.aggregate_kind, + aggregateId: row.stream_id, + occurredAt: row.occurred_at, + commandId: row.command_id, + causationEventId: row.causation_event_id, + correlationId: row.correlation_id, + payload: yield* decodeJson(row.payload_json), + metadata: yield* decodeJson(row.metadata_json), + }); +}); + +function rowToApplicationStoredEvent( + row: ApplicationEventRow, +): Effect.Effect { + return row.aggregate_kind === "project" ? rowToProjectEvent(row) : rowToV2StoredEvent(row); +} + +function inferActorKind(event: UnsequencedProjectEvent): typeof ActorKind.Type { + if (event.commandId !== null && event.commandId.startsWith("provider:")) { + return "provider"; + } + if (event.commandId !== null && event.commandId.startsWith("server:")) { + return "server"; + } + if ( + event.metadata.providerTurnId !== undefined || + event.metadata.providerItemId !== undefined || + event.metadata.adapterKey !== undefined + ) { + return "provider"; + } + if (event.commandId === null) { + return "server"; + } + return "client"; +} + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): OrchestrationEventStoreError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeEventStore = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const committedEvents = yield* PubSub.unbounded(); + + const appendProjectEventRow = SqlSchema.findOne({ + Request: AppendProjectEventRequestSchema, + Result: ProjectEventPersistedRowSchema, + execute: (request) => + sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json, + application_event_version + ) + VALUES ( + ${request.eventId}, + 'project', + ${request.streamId}, + COALESCE( + ( + SELECT stream_version + 1 + FROM orchestration_events + WHERE aggregate_kind = 'project' + AND stream_id = ${request.streamId} + ORDER BY stream_version DESC + LIMIT 1 + ), + 0 + ), + ${request.type}, + ${request.occurredAt}, + ${request.commandId}, + ${request.causationEventId}, + ${request.correlationId}, + ${request.actorKind}, + ${request.payloadJson}, + ${request.metadataJson}, + 2 + ) + RETURNING + sequence, + event_id AS "eventId", + event_type AS "type", + aggregate_kind AS "aggregateKind", + stream_id AS "aggregateId", + occurred_at AS "occurredAt", + command_id AS "commandId", + causation_event_id AS "causationEventId", + correlation_id AS "correlationId", + payload_json AS "payload", + metadata_json AS "metadata" + `, + }); + + const appendProjectEvent: OrchestrationEventStore["Service"]["appendProjectEvent"] = (event) => + appendProjectEventRow({ + eventId: event.eventId, + streamId: event.aggregateId, + type: event.type, + causationEventId: event.causationEventId, + correlationId: event.correlationId, + actorKind: inferActorKind(event), + occurredAt: event.occurredAt, + commandId: event.commandId, + payloadJson: + event.type === "project.deleted" || !event.payload.projectIcon + ? event.payload + : { ...event.payload, projectIcon: encodeProjectIcon(event.payload.projectIcon) }, + metadataJson: event.metadata, + }).pipe( + Effect.flatMap((row) => decodeProjectEvent(row)), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "OrchestrationEventStore.appendProjectEvent:insert", + "OrchestrationEventStore.appendProjectEvent:decode", + ), + ), + ); + + const readApplicationRows = (input: { + readonly afterSequence: number; + readonly throughSequence?: number; + readonly threadId?: ThreadId; + readonly commandId?: CommandId; + readonly eventType?: OrchestrationV2DomainEvent["type"]; + readonly onlyAgentEvents?: boolean; + readonly limit: number; + }) => + sql` + SELECT + sequence, + event_id, + command_id, + aggregate_kind, + stream_id, + event_type, + occurred_at, + payload_json, + metadata_json, + application_event_version, + causation_event_id, + correlation_id + FROM orchestration_events + ${ + input.onlyAgentEvents === true + ? sql`` + : sql`INDEXED BY idx_orchestration_events_application_high_water` + } + WHERE sequence > ${input.afterSequence} + AND sequence <= ${input.throughSequence ?? Number.MAX_SAFE_INTEGER} + AND ( + ${ + input.onlyAgentEvents === true + ? sql`application_event_version = 2 AND aggregate_kind = 'thread'` + : sql`aggregate_kind = 'project' + OR (application_event_version = 2 AND aggregate_kind = 'thread')` + } + ) + AND ${sql.and([ + ...(input.threadId === undefined ? [] : [sql`stream_id = ${input.threadId}`]), + ...(input.commandId === undefined ? [] : [sql`command_id = ${input.commandId}`]), + ...(input.eventType === undefined ? [] : [sql`event_type = ${input.eventType}`]), + ])} + ORDER BY sequence ASC + LIMIT ${input.limit} + `; + + const appendAgentEvents: OrchestrationEventStore["Service"]["appendAgentEvents"] = (input) => + Effect.forEach( + input.events, + (event) => + Effect.gen(function* () { + const encoded = yield* encodeV2EventJson(event); + const rows = yield* sql<{ readonly sequence: number }>` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json, + application_event_version + ) + VALUES ( + ${event.id}, + 'thread', + ${event.threadId}, + COALESCE( + ( + SELECT MAX(stream_version) + 1 + FROM orchestration_events + WHERE aggregate_kind = 'thread' AND stream_id = ${event.threadId} + ), + 0 + ), + ${event.type}, + ${encoded.occurredAt}, + ${input.commandId ?? null}, + NULL, + ${input.commandId ?? null}, + ${event.rawEventId === undefined ? "server" : "provider"}, + ${yield* encodeJson(encoded.payload)}, + ${yield* encodeJson(metadataForV2Event(event))}, + 2 + ) + RETURNING sequence + `; + return yield* decodeV2StoredEvent({ + sequence: rows[0]?.sequence, + commandId: input.commandId ?? null, + event, + }); + }), + { concurrency: 1 }, + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "OrchestrationEventStore.appendAgentEvents:insert", + "OrchestrationEventStore.appendAgentEvents:decode", + ), + ), + ); + + const readAgentEvents: OrchestrationEventStore["Service"]["readAgentEvents"] = (input) => { + const totalLimit = + input?.limit === undefined ? Number.MAX_SAFE_INTEGER : Math.max(0, Math.floor(input.limit)); + if (totalLimit === 0) { + return Stream.empty; + } + return Stream.paginate( + { cursor: input?.afterSequence ?? 0, remaining: totalLimit }, + ({ cursor, remaining }) => { + const pageLimit = Math.min(remaining, READ_PAGE_SIZE); + return readApplicationRows({ + afterSequence: cursor, + ...(input?.throughSequence === undefined + ? {} + : { throughSequence: input.throughSequence }), + ...(input?.threadId === undefined ? {} : { threadId: input.threadId }), + ...(input?.commandId === undefined ? {} : { commandId: input.commandId }), + ...(input?.eventType === undefined ? {} : { eventType: input.eventType }), + onlyAgentEvents: true, + limit: pageLimit, + }).pipe( + Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.readAgentEvents:query")), + Effect.map((rows) => { + const last = rows.at(-1); + const nextRemaining = remaining - rows.length; + return [ + rows, + last === undefined || + rows.length < pageLimit || + nextRemaining <= 0 || + (input?.throughSequence !== undefined && last.sequence >= input.throughSequence) + ? Option.none() + : Option.some({ cursor: last.sequence, remaining: nextRemaining }), + ] as const; + }), + ); + }, + ).pipe( + Stream.mapEffect((row) => + rowToV2StoredEvent(row).pipe( + Effect.mapError( + toPersistenceDecodeError("OrchestrationEventStore.readAgentEvents:decode"), + ), + ), + ), + ); + }; + + const getAgentReplayStats: OrchestrationEventStore["Service"]["getAgentReplayStats"] = (input) => + sql<{ + readonly eventCount: number; + readonly rawPayloadBytes: number; + readonly hasCreateEvent: number; + }>` + SELECT + COUNT(*) AS "eventCount", + COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes", + COALESCE(MAX(event_type = 'thread.created'), 0) AS "hasCreateEvent" + FROM ( + SELECT payload_json, event_type + FROM orchestration_events + WHERE aggregate_kind = 'thread' + AND stream_id = ${input.threadId} + AND application_event_version = 2 + AND sequence > ${input.afterSequence} + AND sequence <= ${input.throughSequence} + ORDER BY sequence ASC + LIMIT ${Math.max(0, Math.floor(input.maxEvents)) + 1} + ) + `.pipe( + Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getAgentReplayStats:query")), + Effect.map((rows) => ({ + eventCount: rows[0]?.eventCount ?? 0, + rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0, + hasCreateEvent: (rows[0]?.hasCreateEvent ?? 0) !== 0, + })), + ); + + const getReplayStats: OrchestrationEventStore["Service"]["getReplayStats"] = (input) => + sql<{ readonly eventCount: number; readonly rawPayloadBytes: number }>` + SELECT + COUNT(*) AS "eventCount", + COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes" + FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water + WHERE sequence > ${input.afterSequence} + AND sequence <= ${input.throughSequence} + AND ( + aggregate_kind = 'project' + OR (application_event_version = 2 AND aggregate_kind = 'thread') + ) + `.pipe( + Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getReplayStats:query")), + Effect.map((rows) => ({ + eventCount: rows[0]?.eventCount ?? 0, + rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0, + })), + ); + + const latestAgentSequence: OrchestrationEventStore["Service"]["latestAgentSequence"] = ( + threadId, + ) => + sql<{ readonly sequence: number | null }>` + SELECT MAX(sequence) AS sequence + FROM orchestration_events + ${ + threadId === undefined + ? sql`` + : sql`INDEXED BY idx_orchestration_events_agent_stream_sequence` + } + WHERE application_event_version = 2 + AND aggregate_kind = 'thread' + ${threadId === undefined ? sql`` : sql`AND stream_id = ${threadId}`} + `.pipe( + Effect.map((rows) => rows[0]?.sequence ?? 0), + Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")), + ); + + // The OR planner otherwise scans every V2 event instead of seeking the final sequence. + const latestApplicationSequence = sql<{ readonly sequence: number | null }>` + SELECT MAX(sequence) AS sequence + FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water + WHERE aggregate_kind = 'project' + OR (application_event_version = 2 AND aggregate_kind = 'thread') + `.pipe( + Effect.map((rows) => rows[0]?.sequence ?? 0), + Effect.mapError( + toPersistenceSqlError("OrchestrationEventStore.latestApplicationSequence:query"), + ), + ); + + const readApplicationEventPage = (input: { + readonly afterSequence: number; + readonly throughSequence: number; + readonly limit: number; + }): Stream.Stream => + Stream.fromEffect( + readApplicationRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("OrchestrationEventStore.readApplicationEvents:query"), + ), + ), + ).pipe( + Stream.flatMap(Stream.fromIterable), + Stream.mapEffect((row) => + rowToApplicationStoredEvent(row).pipe( + Effect.mapError( + toPersistenceDecodeError("OrchestrationEventStore.readApplicationEvents:decode"), + ), + ), + ), + ); + + const catchUpApplicationEvents = (input: { + readonly afterSequence: number; + readonly throughSequence: number; + }): Stream.Stream => { + return Stream.paginate(input.afterSequence, (afterSequence) => + readApplicationEventPage({ + afterSequence, + throughSequence: input.throughSequence, + limit: READ_PAGE_SIZE, + }).pipe( + Stream.runCollect, + Effect.map((events) => { + const last = events.at(-1); + return [ + events, + last === undefined || + events.length < READ_PAGE_SIZE || + last.sequence >= input.throughSequence + ? Option.none() + : Option.some(last.sequence), + ] as const; + }), + ), + ); + }; + + const streamProjectedApplicationEvents: OrchestrationEventStore["Service"]["streamProjectedApplicationEvents"] = + (input) => + replayAndBufferProjectedLiveEvents({ + subscribe: PubSub.subscribe(committedEvents), + latestSequence: latestApplicationSequence, + afterSequence: input.afterSequence ?? 0, + project: input.project, + replay: (throughSequence) => + catchUpApplicationEvents({ + afterSequence: input?.afterSequence ?? 0, + throughSequence, + }), + }).pipe( + Stream.catchTag("LiveStreamBufferError", (cause) => + Stream.fail( + toPersistenceSqlError("OrchestrationEventStore.streamApplicationEvents:buffer")(cause), + ), + ), + ); + + const streamApplicationEvents: OrchestrationEventStore["Service"]["streamApplicationEvents"] = ( + input, + ) => streamProjectedApplicationEvents({ ...input, project: (event) => event }); + + return { + appendProjectEvent, + appendAgentEvents, + readAgentEvents, + getAgentReplayStats, + getReplayStats, + latestAgentSequence, + latestApplicationSequence, + readApplicationEvents: catchUpApplicationEvents, + publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid), + streamApplicationEvents, + streamProjectedApplicationEvents, + } satisfies OrchestrationEventStore["Service"]; +}); + +export const layer = Layer.effect(OrchestrationEventStore, makeEventStore); diff --git a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts index beb016ec3d365a8e6eed55c3152df76d901abb0b..5f3bd059f878e11229384171525ce254880962bc 100644 --- a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts +++ b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts @@ -8,7 +8,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as AuthPairingLinks from "./AuthPairingLinks.ts"; import * as AuthSessions from "./AuthSessions.ts"; import * as PersistenceErrors from "./Errors.ts"; -import * as SqlitePersistence from "./Layers/Sqlite.ts"; +import * as SqlitePersistence from "./Sqlite.ts"; import * as ProviderSessionRuntime from "./ProviderSessionRuntime.ts"; const issuedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts deleted file mode 100644 index 16b63771c5122b39d8ee9e43475d09a2e66e88e8..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * OrchestrationCommandReceiptRepository - Repository interface for command receipts. - * - * Owns persistence operations for deduplication and status tracking of - * orchestration command handling. - * - * @module OrchestrationCommandReceiptRepository - */ -import { CommandId, IsoDateTime, NonNegativeInt, ProjectId, ThreadId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; - -export const OrchestrationCommandReceipt = Schema.Struct({ - commandId: CommandId, - aggregateKind: Schema.Literals(["project", "thread"]), - aggregateId: Schema.Union([ProjectId, ThreadId]), - commandType: Schema.String, - acceptedAt: IsoDateTime, - resultSequence: NonNegativeInt, - status: Schema.Literals(["accepted", "rejected"]), - error: Schema.NullOr(Schema.String), -}); -export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type; - -export const GetByCommandIdInput = Schema.Struct({ - commandId: CommandId, -}); -export type GetByCommandIdInput = typeof GetByCommandIdInput.Type; - -/** - * OrchestrationCommandReceiptRepositoryShape - Service API for command receipts. - */ -export interface OrchestrationCommandReceiptRepositoryShape { - readonly insertIfAbsent: ( - receipt: OrchestrationCommandReceipt, - ) => Effect.Effect; - - /** - * Insert or replace a command receipt row. - * - * Upserts by `commandId` for idempotent command-result tracking. - */ - readonly upsert: ( - receipt: OrchestrationCommandReceipt, - ) => Effect.Effect; - - /** - * Read a command receipt by command id. - */ - readonly getByCommandId: ( - input: GetByCommandIdInput, - ) => Effect.Effect< - Option.Option, - OrchestrationCommandReceiptRepositoryError - >; -} - -/** - * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence. - */ -export class OrchestrationCommandReceiptRepository extends Context.Service< - OrchestrationCommandReceiptRepository, - OrchestrationCommandReceiptRepositoryShape ->()("t3/persistence/Services/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {} diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts deleted file mode 100644 index bb7ff301afd19ca1ea94e240754d4040a323f1de..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Historical name for the shared application event store. - * - * Owns durable append/replay access for project events and V2 agent-thread - * events under one global sequence. It does not reduce events into read models - * or apply command validation rules. - * - * Uses Effect `Context.Service` for dependency injection and exposes typed - * persistence/decode errors for event append and replay operations. - * - * @module OrchestrationEventStore - */ -import type { - ApplicationProjectEvent, - ApplicationStoredEvent, - CommandId, - OrchestrationV2DomainEvent, - OrchestrationV2StoredEvent, - ThreadId, -} 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 { OrchestrationEventStoreError } from "../Errors.ts"; - -/** A project event before the store assigns its sequence. */ -export type UnsequencedProjectEvent = ApplicationProjectEvent extends infer Event - ? Event extends ApplicationProjectEvent - ? Omit - : never - : never; - -/** - * OrchestrationEventStoreShape - Service API for orchestration event persistence. - */ -export interface OrchestrationEventStoreShape { - /** Append one project event to the shared application log. */ - readonly appendProjectEvent: ( - event: UnsequencedProjectEvent, - ) => Effect.Effect; - - /** Append V2 agent events to the same globally ordered application log. */ - readonly appendAgentEvents: (input: { - readonly commandId?: CommandId; - readonly events: ReadonlyArray; - }) => Effect.Effect, OrchestrationEventStoreError>; - - /** - * Read only V2 thread events from the application log. - * - * Reads in fixed-size sequence pages until the filtered range is exhausted; - * `limit` caps the total emitted events across pages. - */ - readonly readAgentEvents: (input?: { - readonly afterSequence?: number; - readonly throughSequence?: number; - readonly threadId?: ThreadId; - readonly commandId?: CommandId; - readonly eventType?: OrchestrationV2DomainEvent["type"]; - readonly limit?: number; - }) => Stream.Stream; - - /** Measure one thread's bounded replay without loading or decoding its payloads. */ - readonly getAgentReplayStats: (input: { - readonly threadId: ThreadId; - readonly afterSequence: number; - readonly throughSequence: number; - readonly maxEvents: number; - }) => Effect.Effect< - { - readonly eventCount: number; - /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */ - readonly rawPayloadBytes: number; - readonly hasCreateEvent: boolean; - }, - OrchestrationEventStoreError - >; - - /** - * Measure the retained application-event range `(afterSequence, throughSequence]` - * without loading or decoding its payloads. - */ - readonly getReplayStats: (input: { - readonly afterSequence: number; - readonly throughSequence: number; - }) => Effect.Effect< - { - readonly eventCount: number; - /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */ - readonly rawPayloadBytes: number; - }, - OrchestrationEventStoreError - >; - - readonly latestAgentSequence: ( - threadId?: ThreadId, - ) => Effect.Effect; - - readonly latestApplicationSequence: Effect.Effect; - - /** Read the finite retained application-event range `(afterSequence, throughSequence]`. */ - readonly readApplicationEvents: (input: { - readonly afterSequence: number; - readonly throughSequence: number; - }) => Stream.Stream; - - /** Publish only after the surrounding event/projection transaction commits. */ - readonly publishCommitted: (events: ReadonlyArray) => Effect.Effect; - - /** Race-free replay-to-live stream for project and V2 thread events. */ - readonly streamApplicationEvents: (input?: { - readonly afterSequence?: number; - }) => Stream.Stream; - /** Project transport events before bounding replay and the live tail. */ - readonly streamProjectedApplicationEvents: (input: { - readonly afterSequence?: number; - readonly project: (event: ApplicationStoredEvent) => A; - }) => Stream.Stream; -} - -/** OrchestrationEventStore - Service tag for the shared application event log. */ -export class OrchestrationEventStore extends Context.Service< - OrchestrationEventStore, - OrchestrationEventStoreShape ->()("t3/persistence/Services/OrchestrationEventStore") {} diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bdacd0d13d7814905f2e00b736a6191d02becad --- /dev/null +++ b/apps/server/src/persistence/Sqlite.test.ts @@ -0,0 +1,93 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { WAL_SIZE_LIMIT_BYTES } from "./Sqlite.ts"; +import * as SqlitePersistence from "./Sqlite.ts"; + +const lockHolderSource = ` +const { DatabaseSync } = require("node:sqlite"); +const db = new DatabaseSync(process.argv[1]); +db.exec("BEGIN IMMEDIATE"); +process.stdout.write("locked\\n"); +setTimeout(() => { + db.exec("COMMIT"); + db.close(); +}, Number(process.argv[2])); +`; + +const spawnWriteLockHolder = (dbPath: string, holdMs: number) => + Effect.promise( + () => + new Promise((resolve, reject) => { + const holder = NodeChildProcess.spawn( + process.execPath, + ["-e", lockHolderSource, dbPath, String(holdMs)], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + holder.stdout.once("data", () => resolve()); + holder.on("error", reject); + holder.on("exit", () => + reject(new Error("lock holder exited before acquiring the write lock")), + ); + }), + ); + +it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`; + yield* spawnWriteLockHolder(dbPath, 300); + yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`; + const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`; + assert.deepEqual([...rows], [{ id: 1 }]); + }).pipe( + Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + +it.effect("shrinks the WAL file back to the size limit after a large write", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size; + // About 25% more 4 KB rows than the limit holds, in one transaction. + const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE wal_probe(payload BLOB)`; + yield* sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount}) + INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n + `; + assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES); + + // The auto-checkpoint after the large commit copied every frame into the + // database, so the next commit restarts the WAL and cuts the file back. + yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`; + assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES); + }).pipe( + Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + +it.effect("applies busy_timeout in the shared persistence setup", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`; + assert.equal(rows[0]?.timeout, 5000); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f248b5a2387776659f95aa20fdacc8a893a51b6 --- /dev/null +++ b/apps/server/src/persistence/Sqlite.ts @@ -0,0 +1,57 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { runMigrations } from "./Migrations.ts"; +import { initializeV2Database } from "./initializeV2Database.ts"; +import * as ServerConfig from "../config.ts"; + +// Size the -wal file is cut back to on the first commit after a WAL reset. +export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024; + +const layerSetup = Layer.effectDiscard( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY. + yield* sql`PRAGMA busy_timeout = 5000;`; + yield* sql`PRAGMA foreign_keys = ON;`; + yield* sql`PRAGMA journal_mode = WAL;`; + // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its + // largest size until the last connection closes. + yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); + yield* runMigrations(); + }), +); + +export const layerFromPath = Effect.fn("makeSqlitePersistenceLive")(function* (dbPath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); + + return Layer.provideMerge( + layerSetup, + NodeSqliteClient.layer({ + filename: dbPath, + spanAttributes: { + "db.name": path.basename(dbPath), + "service.name": "t3code-server", + }, + }), + ); +}, Layer.unwrap); + +export const layerMemory = Layer.provideMerge( + layerSetup, + NodeSqliteClient.layer({ filename: ":memory:" }), +); + +export const layerConfig = Layer.unwrap( + Effect.gen(function* () { + const { dbPath } = yield* ServerConfig.ServerConfig; + yield* initializeV2Database(dbPath); + return layerFromPath(dbPath); + }), +); diff --git a/apps/server/src/persistence/initializeV2Database.test.ts b/apps/server/src/persistence/initializeV2Database.test.ts index d9795b9a43d4606721dc8faf304d0145d0e782f2..09b10dfa571e77866e282f248fdd643877077486 100644 --- a/apps/server/src/persistence/initializeV2Database.test.ts +++ b/apps/server/src/persistence/initializeV2Database.test.ts @@ -13,7 +13,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; -import * as SqlitePersistence from "./Layers/Sqlite.ts"; +import * as SqlitePersistence from "./Sqlite.ts"; import { runMigrations } from "./Migrations.ts"; import { initializeV2Database } from "./initializeV2Database.ts"; import * as EventStore from "../orchestration-v2/EventStore.ts"; diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index fad54abeb8b03e6ef9040f749ae087720c8a8c83..cd490079f2cc7183c3bf317b2e477cdc5daec32d 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -13,7 +13,7 @@ import * as Scope from "effect/Scope"; import * as ServerConfig from "../config.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 78e3d8bb23f29e3ba08c884476fd2518ef1c0db2..48ad979d151bde653cd9d307f7cdb71613a64f74 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -27,7 +27,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index d949363be2a699458500f1eaac1ea86fb4b8e178..809184b36d098c91af7e55606cce742c5f4c6eb8 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -18,7 +18,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; diff --git a/apps/server/src/provider/AcpRegistryCatalog.ts b/apps/server/src/provider/AcpRegistryCatalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..9141793ee5455f82569485dce77026bd2f61d245 --- /dev/null +++ b/apps/server/src/provider/AcpRegistryCatalog.ts @@ -0,0 +1,22 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; +import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts"; +import * as AcpRegistryRuntimeCoordinator from "./acp/AcpRegistryRuntimeCoordinator.ts"; + +/** Server-lifetime ACP Registry catalog shared by setup, snapshots, and turn launch. */ +export const layer = Layer.merge( + Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + return AcpRegistrySupport.AcpRegistryCatalog.layer({ + cacheDir: config.providerStatusCacheDir, + toolsDir: path.join(config.baseDir, "tools"), + }); + }), + ), + AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer, +); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index 768c07dd86fe7122c3e5a748a4ce242ddebe7e09..d25ac40f32e751841500f63244ce74a2b7b3a9fe 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -28,7 +28,7 @@ import { forwardAntigravityCallback, validateAntigravityCallbackUrl, } from "./antigravityCallback.ts"; -import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; +import type { ProviderAuthController } from "./ProviderAuthService.ts"; const AUTH_TIMEOUT_MS = 300_000; const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again."; diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..259b5d5cf4cc0fc771a016202e6dfc48636c9831 --- /dev/null +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -0,0 +1,661 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + ANTIGRAVITY_DEFAULT_MODEL, + AntigravitySettings, + ProviderDriverKind, + ProviderInstanceId, + ProviderSetupError, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import { + buildAntigravityModelsFromSession, + makeAntigravityProvider, +} from "./AntigravityProvider.ts"; + +const decodeSettings = Schema.decodeSync(AntigravitySettings); +const instanceId = ProviderInstanceId.make("antigravity-test"); +const driver = ProviderDriverKind.make("antigravity"); + +const initializeResult = { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, + sessionCapabilities: { list: {}, resume: {} }, + }, + authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], + agentInfo: { + name: "antigravity-acp", + title: "Google Antigravity", + version: "agy_acp_server_20260818_01_RC01", + }, +} satisfies EffectAcpSchema.InitializeResponse; + +const modelOptions = [ + { value: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" }, + { value: "gemini-3.8-flash-medium", name: "Gemini 3.8 Flash (Medium)" }, + { value: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" }, + { value: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" }, + { value: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" }, + { value: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)" }, + { value: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" }, + { value: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" }, + { value: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" }, + { value: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, + { value: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, +]; + +const modelConfig = { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "gemini-3.7-flash-high", + options: modelOptions, +} satisfies EffectAcpSchema.SessionConfigOption; + +const sessionSetupResult = { + sessionId: "session-1", + models: { + currentModelId: "gemini-3.7-flash-high", + availableModels: modelOptions.map((option) => ({ modelId: option.value, name: option.name })), + }, + configOptions: [ + modelConfig, + { + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "yolo", name: "YOLO" }, + ], + }, + ], +} satisfies EffectAcpSchema.NewSessionResponse; + +const started = { + sessionId: "session-1", + initializeResult, + sessionSetupResult, + modelConfigId: "model", +} satisfies AcpSessionRuntimeStartResult; + +const commands = [ + { name: "plan", description: "Create a plan", input: { type: "text", hint: "What to plan" } }, + { name: "logout", description: "Sign out of Google" }, +] satisfies ReadonlyArray; + +const expectedCommands = [ + { name: "plan", description: "Create a plan", input: { hint: "What to plan" } }, + { name: "logout", description: "Sign out of Google" }, +]; + +const layerTest = Layer.merge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ServerSettings.layerTest(), +); + +type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; + +const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( + options: { readonly enabled?: boolean; readonly safe?: boolean } = {}, +) { + const initialProbe = yield* Deferred.make(); + const probeCalls = yield* Ref.make(0); + const safetyCalls = yield* Ref.make(0); + const probe = yield* Ref.make>( + Deferred.await(initialProbe), + ); + const safety = yield* Ref.make>(Effect.succeed(options.safe ?? true)); + const provider = yield* makeAntigravityProvider( + decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }), + { + stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), + probe: Ref.update(probeCalls, (count) => count + 1).pipe( + Effect.andThen(Ref.get(probe)), + Effect.flatten, + ), + supportsTextGeneration: Ref.update(safetyCalls, (count) => count + 1).pipe( + Effect.andThen(Ref.get(safety)), + Effect.flatten, + ), + }, + ); + const initialUpdate = yield* Stream.toPull( + provider.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.installed || snapshot.status === "error"), + ), + ); + const initialize = Deferred.succeed(initialProbe, initializeResult).pipe( + Effect.andThen(initialUpdate), + Effect.asVoid, + ); + return { + provider, + probe, + probeCalls, + safety, + safetyCalls, + initialProbe, + initialUpdate, + initialize, + }; +}); + +describe("Antigravity model catalog", () => { + it("keeps the captured personal catalog's IDs, labels, order, and selected default", () => { + const models = buildAntigravityModelsFromSession(sessionSetupResult); + expect(models.map((model) => [model.slug, model.name])).toEqual( + modelOptions.map((option) => [option.value, option.name]), + ); + expect(models.filter((model) => model.isDefault).map((model) => model.slug)).toEqual([ + "gemini-3.7-flash-high", + ]); + expect( + models + .filter((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) + .map((model) => model.slug), + ).toEqual(["gemini-3.7-flash-high"]); + expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 0)).toBe(true); + expect(models.every((model) => !model.isCustom)).toBe(true); + }); + + it("uses legacy session models only when model config is absent", () => { + const fromLegacy = buildAntigravityModelsFromSession({ + models: sessionSetupResult.models, + }); + expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect( + buildAntigravityModelsFromSession({ + ...sessionSetupResult, + configOptions: [{ ...modelConfig, options: [] }], + }), + ).toEqual([]); + }); + + it("flattens native option groups without combining distinct model IDs", () => { + const models = buildAntigravityModelsFromSession({ + configOptions: [ + { + ...modelConfig, + currentValue: "gemini-pro-agent", + options: [ + { groupId: "Flash", name: "Flash", options: [modelOptions[3]!, modelOptions[4]!] }, + { groupId: "Pro", name: "Pro", options: [modelOptions[9]!, modelOptions[3]!] }, + ], + }, + ], + }); + expect(models.map((model) => model.slug)).toEqual([ + "gemini-3.7-flash-high", + "gemini-3.7-flash-medium", + "gemini-pro-agent", + ]); + expect(models.find((model) => model.isDefault)?.slug).toBe("gemini-pro-agent"); + expect(models.find((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))?.slug).toBe( + "gemini-pro-agent", + ); + }); +}); + +it.layer(layerTest)("Antigravity provider snapshots", (it) => { + it.effect("does not probe or run helper safety checks while disabled", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ enabled: false }); + const snapshot = yield* harness.provider.snapshot.refresh; + expect(snapshot).toMatchObject({ + enabled: false, + installed: false, + status: "disabled", + auth: { status: "unknown" }, + models: [], + setup: { canAuthenticate: true, canInstall: true }, + showInteractionModeToggle: false, + supportsConversationRollback: false, + supportsTextGeneration: false, + }); + expect(yield* Ref.get(harness.probeCalls)).toBe(0); + expect(yield* Ref.get(harness.safetyCalls)).toBe(0); + }), + ), + ); + + it.effect("records explicit sign-in while disabled without starting a health probe", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ enabled: false }); + const signedIn = yield* Stream.toPull( + harness.provider.snapshot.streamChanges.pipe( + Stream.filter( + (snapshot) => + snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, + ), + ), + ); + yield* harness.provider.onSessionStarted(started); + yield* harness.provider.onAvailableCommands(commands); + const [snapshot] = yield* signedIn; + expect(snapshot).toMatchObject({ + enabled: false, + installed: true, + status: "disabled", + auth: { status: "authenticated", type: "oauth-personal" }, + workspaceSnapshots: [], + }); + expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect(snapshot.slashCommands).toEqual(expectedCommands); + expect((yield* harness.provider.snapshot.refresh).models).toEqual(snapshot.models); + expect(yield* Ref.get(harness.probeCalls)).toBe(0); + }), + ), + ); + + it.effect("publishes the configured sign-in method before any account is checked", () => + Effect.scoped( + Effect.gen(function* () { + const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), { + stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), + probe: Effect.succeed(initializeResult), + supportsTextGeneration: Effect.succeed(true), + auth: { type: "gemini-api-key", label: "Gemini API key" }, + }); + expect((yield* provider.snapshot.getSnapshot).auth).toEqual({ + status: "unknown", + type: "gemini-api-key", + }); + }), + ), + ); + + it.effect("treats initialize as installation proof, not account or model discovery", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ + installed: false, + status: "warning", + auth: { status: "unknown" }, + models: [], + }); + yield* harness.initialize; + const snapshot = yield* harness.provider.snapshot.getSnapshot; + expect(snapshot).toMatchObject({ + installed: true, + status: "warning", + version: "agy_acp_server_20260818_01_RC01", + auth: { status: "unknown" }, + models: [], + }); + expect(yield* Ref.get(harness.probeCalls)).toBe(1); + }), + ), + ); + + it.effect("publishes session metadata and native commands without another health probe", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const nextReady = yield* Stream.toPull( + harness.provider.snapshot.streamChanges.pipe( + Stream.filter( + (snapshot) => + snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, + ), + ), + ); + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + const [snapshot] = yield* nextReady; + expect(snapshot).toMatchObject({ + status: "ready", + auth: { status: "authenticated", type: "oauth-personal" }, + supportsTextGeneration: true, + }); + expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect(snapshot.slashCommands).toEqual(expectedCommands); + expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual( + expectedCommands, + ); + expect(yield* Ref.get(harness.probeCalls)).toBe(1); + }), + ), + ); + + it.effect("does not retain a disposable sign-in workspace", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + yield* harness.provider.onAvailableCommands(commands); + const snapshot = yield* harness.provider.snapshot.getSnapshot; + expect(snapshot.models).toHaveLength(11); + expect(snapshot.slashCommands).toEqual(expectedCommands); + expect(snapshot.workspaceSnapshots).toEqual([]); + }), + ), + ); + + it.effect("clears all account metadata on sign-out and authentication failure", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + for (const clear of [harness.provider.onSignedOut, harness.provider.onAuthRequired]) { + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* clear; + expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ + installed: true, + status: "warning", + auth: { status: "unauthenticated" }, + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [], + supportsTextGeneration: false, + }); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* harness.provider.onConfigOptionsUpdated([modelConfig]); + expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]); + expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); + } + const refreshed = yield* harness.provider.snapshot.refresh; + expect(refreshed.auth.status).toBe("unauthenticated"); + expect(refreshed.supportsTextGeneration).toBe(false); + }), + ), + ); + + it.effect("replaces live model choices and accepts an empty catalog", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + const before = yield* harness.provider.snapshot.getSnapshot; + const configOptions = [ + { + ...modelConfig, + currentValue: "gemini-3.8-flash-high", + options: modelOptions.slice(0, 3), + }, + ]; + const nextSnapshot = yield* Stream.toPull( + harness.provider.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.models.length === 3), + ), + ); + yield* harness.provider.onConfigOptionsUpdated(configOptions); + expect((yield* nextSnapshot)[0]).toMatchObject({ + models: buildAntigravityModelsFromSession({ configOptions }), + auth: before.auth, + workspaceSnapshots: before.workspaceSnapshots, + slashCommands: expectedCommands, + }); + yield* harness.provider.onConfigOptionsUpdated([]); + expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); + }), + ), + ); + + it.effect("replaces one account's catalog instead of combining accounts", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + yield* harness.provider.onSignedOut; + yield* harness.provider.onSessionStarted({ + ...started, + sessionSetupResult: { + sessionId: "new-account-session", + configOptions: [ + { ...modelConfig, currentValue: "gemini-pro-agent", options: [modelOptions[9]!] }, + ], + }, + }); + expect( + (yield* harness.provider.snapshot.getSnapshot).models.map((model) => model.slug), + ).toEqual(["gemini-pro-agent"]); + }), + ), + ); + + it.effect("retains known account metadata when a local health check fails", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* Ref.set( + harness.probe, + Effect.fail(EffectAcpErrors.AcpRequestError.internalError("probe failed")), + ); + const snapshot = yield* harness.provider.snapshot.refresh; + expect(snapshot).toMatchObject({ + installed: true, + status: "error", + auth: { status: "authenticated" }, + }); + expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect(snapshot.slashCommands).toEqual(expectedCommands); + expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace"); + }), + ), + ); + + it.effect("allows a slow packaged runtime health check to finish", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const entered = yield* Deferred.make(); + const initialized = yield* Deferred.make(); + yield* Ref.set( + harness.probe, + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(initialized))), + ); + + const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* TestClock.adjust("47 seconds"); + yield* Deferred.succeed(initialized, initializeResult); + const snapshot = yield* Fiber.join(refresh); + + expect(snapshot).toMatchObject({ + installed: true, + status: "warning", + auth: { status: "unknown" }, + }); + }), + ), + ); + + it.effect("closes a stalled health probe at its deadline", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const entered = yield* Deferred.make(); + const closed = yield* Deferred.make(); + yield* Ref.set( + harness.probe, + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(closed, undefined)), + ), + ); + const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* TestClock.adjust("90 seconds"); + const snapshot = yield* Fiber.join(refresh); + yield* Deferred.await(closed); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("90 seconds"); + }), + ), + ); + + it.effect("distinguishes missing executables from a failed installed executable", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const failures = [ + { + error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "ENOENT" } }), + installed: false, + }, + { + error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "EACCES" } }), + installed: true, + }, + { + error: new ProviderSetupError({ + instanceId, + operation: "resolve", + detail: "Antigravity is not installed.", + }), + installed: false, + }, + ]; + for (const { error, installed } of failures) { + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* Ref.set(harness.probe, Effect.fail(error)); + const snapshot = yield* harness.provider.snapshot.refresh; + expect(snapshot).toMatchObject({ + installed, + status: "error", + auth: { status: "authenticated" }, + }); + expect(snapshot.models).toHaveLength(installed ? 11 : 0); + expect(snapshot.slashCommands).toHaveLength(installed ? 2 : 0); + expect(snapshot.workspaceSnapshots).toHaveLength(installed ? 1 : 0); + expect(snapshot.supportsTextGeneration).toBe(installed); + } + }), + ), + ); + + it.effect("does not let an old health result restore a signed-out account", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + const releaseProbe = yield* Deferred.make(); + const probeEntered = yield* Deferred.make(); + yield* Ref.set( + harness.probe, + Deferred.succeed(probeEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseProbe)), + ), + ); + const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); + yield* Deferred.await(probeEntered); + yield* harness.provider.onSignedOut; + yield* Deferred.succeed(releaseProbe, initializeResult); + const snapshot = yield* Fiber.join(refresh); + expect(snapshot).toMatchObject({ + auth: { status: "unauthenticated" }, + models: [], + supportsTextGeneration: false, + }); + }), + ), + ); + + it.effect("exposes helper support only when the supplied safety check allows it", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ safe: false }); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(false); + yield* Ref.set(harness.safety, Effect.succeed(true)); + yield* harness.provider.snapshot.refresh; + expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(true); + }), + ), + ); + + it.effect("keeps discovered workspace skills through session and command updates", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const skills = [ + { + name: "deploy", + description: "Ship it", + path: "/workspace/.agent/skills/deploy", + enabled: true, + }, + ]; + const discovered = yield* harness.provider.snapshotForCwd("/workspace", skills); + expect(discovered.skills).toEqual(skills); + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + const after = yield* harness.provider.snapshot.getSnapshot; + expect( + after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills, + ).toEqual(skills); + expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills); + + const rescanned = [ + ...skills, + { name: "review", path: "/workspace/.agent/skills/review", enabled: true }, + ]; + yield* harness.provider.snapshotForCwd("/workspace", rescanned); + yield* harness.provider.onSessionStarted(started, "/workspace"); + expect( + (yield* harness.provider.snapshot.getSnapshot).workspaceSnapshots?.find( + (entry) => entry.cwd === "/workspace", + )?.skills, + ).toEqual(rescanned); + }), + ), + ); + + it.effect("bounds workspace metadata without starting sessions for workspace lookup", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + for (let index = 0; index < 35; index++) { + yield* harness.provider.onAvailableCommands(commands, `/workspace-${index}`); + } + const snapshot = yield* harness.provider.snapshotForCwd("/workspace-34"); + expect(snapshot.workspaceSnapshots).toHaveLength(32); + expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace-3"); + expect(snapshot.slashCommands).toEqual(expectedCommands); + expect(yield* Ref.get(harness.probeCalls)).toBe(1); + }), + ), + ); +}); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..a644b020d38c32547aa7ca469bddbc88705f5789 --- /dev/null +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -0,0 +1,415 @@ +import { + ANTIGRAVITY_DEFAULT_MODEL, + ProviderDriverKind, + type AntigravitySettings, + type ProviderSetupError, + type ServerProvider, + type ServerProviderModel, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilities, +} from "./providerMaintenance.ts"; +import { + buildServerProvider, + isCommandMissingCause, + type ServerProviderDraft, +} from "./providerSnapshot.ts"; + +const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); +const MAX_WORKSPACE_SNAPSHOTS = 32; +const HEALTH_CHECK_TIMEOUT = "90 seconds"; +const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; +const AUTH_UNCHECKED_MESSAGE = + "Antigravity is installed. Google account access is not checked yet."; + +type SessionSetupResult = Pick< + AcpSessionRuntimeStartResult["sessionSetupResult"], + "configOptions" | "models" +>; + +/** Keep the native model IDs, including model-specific thinking levels. */ +export function buildAntigravityModelsFromSession( + setup: SessionSetupResult, +): ReadonlyArray { + const config = setup.configOptions?.find( + (option) => option.id === "model" || option.category === "model", + ); + const currentValue = + config?.type === "select" ? config.currentValue : setup.models?.currentModelId; + const entries = + config?.type === "select" + ? config.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)) + : config === undefined + ? (setup.models?.availableModels.map((model) => ({ + value: model.modelId, + name: model.name, + })) ?? []) + : []; + const seen = new Set(); + return entries.flatMap((entry): ServerProviderModel[] => { + if (!entry.value.trim() || seen.has(entry.value)) return []; + seen.add(entry.value); + return [ + { + slug: entry.value, + name: entry.name.trim() ? entry.name : entry.value, + isCustom: false, + ...(entry.value === currentValue + ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] } + : {}), + capabilities: EMPTY_MODEL_CAPABILITIES, + }, + ]; + }); +} + +function nativeCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return commands.flatMap((command): ServerProviderSlashCommand[] => { + if (!command.name.trim() || seen.has(command.name)) return []; + seen.add(command.name); + const description = command.description.trim(); + const hint = command.input?.hint.trim(); + return [ + { + name: command.name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + }, + ]; + }); +} + +function isMissingInstallation(error: EffectAcpErrors.AcpError | ProviderSetupError): boolean { + if (error._tag === "AcpSpawnError") { + return ( + isCommandMissingCause(error.cause) || + (Predicate.isObject(error.cause) && error.cause.code === "ENOENT") + ); + } + return ( + error._tag === "ProviderSetupError" && + error.operation === "resolve" && + /not installed|missing|incomplete|does not publish/i.test(error.detail) + ); +} + +interface AntigravityProviderState { + readonly draft: ServerProviderDraft; + readonly authRevision: number; +} + +interface AntigravityProviderOptions { + readonly stampIdentity: (snapshot: ServerProviderDraft) => Effect.Effect; + readonly probe: Effect.Effect< + EffectAcpSchema.InitializeResponse, + EffectAcpErrors.AcpError | ProviderSetupError + >; + readonly supportsTextGeneration: Effect.Effect; + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; + /** Auth type and label published once a session authenticates. */ + readonly auth?: { readonly type: string; readonly label: string }; +} + +/** Health uses initialize only. Session callbacks supply account-specific metadata. */ +export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* ( + settings: AntigravitySettings, + options: AntigravityProviderOptions, +) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const initialDraft = { + ...buildServerProvider({ + presentation: { displayName: "Antigravity", showInteractionModeToggle: false }, + enabled: settings.enabled, + checkedAt, + models: [], + probe: { + installed: false, + version: null, + status: "warning", + // The configured method rides along so the registry can tell a saved + // account for this method from one left by a previous configuration. + auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) }, + message: settings.enabled + ? "Checking Antigravity availability." + : "Antigravity is disabled in T3 Code settings.", + }, + }), + setup: { canAuthenticate: true, canInstall: true }, + supportsConversationRollback: false, + supportsTextGeneration: false, + workspaceSnapshots: [], + } satisfies ServerProviderDraft; + const metadata = yield* SubscriptionRef.make({ + draft: initialDraft, + authRevision: 0, + }); + // Skills the driver discovered on disk per workspace. Session callbacks + // rewrite the workspace entry with native commands and must keep these, or + // the registry drops the suggestions and never re-reads the workspace. + const discoveredSkills = new Map(); + const getSnapshot = SubscriptionRef.get(metadata).pipe( + Effect.flatMap((state) => options.stampIdentity(state.draft)), + ); + + const checkProvider = Effect.fn("checkAntigravityProvider")(function* () { + if (!settings.enabled) return yield* getSnapshot; + const before = yield* SubscriptionRef.get(metadata); + const result = yield* options.probe.pipe( + Effect.timeoutOption(HEALTH_CHECK_TIMEOUT), + Effect.result, + ); + const initialized = + Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined; + const failure = Result.isFailure(result) ? result.failure : undefined; + const missingInstallation = failure !== undefined && isMissingInstallation(failure); + const errorMessage = + initialized !== undefined + ? undefined + : failure?._tag === "ProviderSetupError" + ? failure.detail.trim() || "Antigravity could not complete its local health check." + : missingInstallation + ? "Antigravity is not installed or its executable could not be found." + : failure + ? "Antigravity could not complete its local health check." + : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; + const supportsTextGeneration = + initialized !== undefined ? yield* options.supportsTextGeneration : false; + const updatedAt = DateTime.formatIso(yield* DateTime.now); + const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { + if (state.authRevision !== before.authRevision) return state; + const { message: _previousMessage, ...draft } = state.draft; + const authenticated = draft.auth.status === "authenticated"; + const message = + errorMessage ?? + (authenticated + ? undefined + : draft.auth.status === "unauthenticated" + ? SIGN_IN_MESSAGE + : AUTH_UNCHECKED_MESSAGE); + return { + ...state, + draft: { + ...draft, + installed: !missingInstallation, + version: initialized?.agentInfo?.version || draft.version, + status: errorMessage ? "error" : authenticated ? "ready" : "warning", + checkedAt: updatedAt, + ...(missingInstallation + ? { + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [], + supportsTextGeneration: false, + } + : {}), + ...(initialized !== undefined + ? { + supportsTextGeneration: + supportsTextGeneration && draft.auth.status !== "unauthenticated", + } + : {}), + ...(message ? { message } : {}), + }, + } satisfies AntigravityProviderState; + }); + return yield* options.stampIdentity(next.draft); + }); + + const maintenanceCapabilities = + options.maintenanceCapabilities ?? + makeManualOnlyProviderMaintenanceCapabilities({ + provider: ProviderDriverKind.make("antigravity"), + packageName: null, + }); + const managed = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed(settings), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => getSnapshot, + checkProvider: checkProvider(), + enrichSnapshot: ({ publishSnapshot }) => + SubscriptionRef.changes(metadata).pipe( + Stream.runForEach((state) => + options.stampIdentity(state.draft).pipe(Effect.flatMap(publishSnapshot)), + ), + ), + }); + + const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( + started: AcpSessionRuntimeStartResult, + cwd?: string, + ) { + const before = yield* SubscriptionRef.get(metadata); + const supportsTextGeneration = yield* options.supportsTextGeneration; + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update(metadata, (state) => { + if ( + state.authRevision !== before.authRevision && + state.draft.auth.status === "unauthenticated" + ) { + return state; + } + const { message: _previousMessage, ...draft } = state.draft; + const workspaces = draft.workspaceSnapshots ?? []; + const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined; + return { + authRevision: state.authRevision + 1, + draft: { + ...draft, + installed: true, + status: settings.enabled ? "ready" : "disabled", + version: started.initializeResult.agentInfo?.version || draft.version, + auth: { + status: "authenticated", + type: options.auth?.type ?? "oauth-personal", + label: options.auth?.label ?? "Google account", + }, + checkedAt: updatedAt, + models: buildAntigravityModelsFromSession(started.sessionSetupResult), + supportsTextGeneration, + ...(cwd + ? { + workspaceSnapshots: [ + ...workspaces.filter((entry) => entry.cwd !== cwd), + { + cwd, + checkedAt: updatedAt, + slashCommands: workspace?.slashCommands ?? draft.slashCommands, + skills: workspace?.skills ?? discoveredSkills.get(cwd) ?? [], + }, + ].slice(-MAX_WORKSPACE_SNAPSHOTS), + } + : {}), + }, + } satisfies AntigravityProviderState; + }); + }); + + const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* ( + configOptions: ReadonlyArray, + ) { + const models = buildAntigravityModelsFromSession({ configOptions }); + yield* SubscriptionRef.update(metadata, (state) => { + if (state.draft.auth.status !== "authenticated") return state; + return { ...state, draft: { ...state.draft, models } }; + }); + }); + + const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* ( + commands: ReadonlyArray, + cwd?: string, + ) { + const slashCommands = nativeCommands(commands); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update(metadata, (state) => { + if (state.draft.auth.status === "unauthenticated") return state; + return { + ...state, + draft: { + ...state.draft, + slashCommands, + ...(cwd + ? { + workspaceSnapshots: [ + ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd), + { + cwd, + checkedAt: updatedAt, + slashCommands, + skills: + state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.skills ?? + discoveredSkills.get(cwd) ?? + [], + }, + ].slice(-MAX_WORKSPACE_SNAPSHOTS), + } + : {}), + }, + }; + }); + }); + + const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update( + metadata, + (state) => + ({ + authRevision: state.authRevision + 1, + draft: { + ...state.draft, + auth: { status: "unauthenticated" }, + status: settings.enabled ? "warning" : "disabled", + message: SIGN_IN_MESSAGE, + checkedAt: updatedAt, + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [], + supportsTextGeneration: false, + }, + }) satisfies AntigravityProviderState, + ); + discoveredSkills.clear(); + }); + + const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( + cwd: string, + skills?: ServerProvider["skills"], + ) { + if (skills) { + discoveredSkills.set(cwd, skills); + // A rescan replaces the stored entry's skills. Session callbacks and + // health checks republish that entry, so it must not keep old ones. + yield* SubscriptionRef.update(metadata, (state) => + state.draft.workspaceSnapshots?.some((entry) => entry.cwd === cwd) + ? { + ...state, + draft: { + ...state.draft, + workspaceSnapshots: state.draft.workspaceSnapshots.map((entry) => + entry.cwd === cwd ? { ...entry, skills } : entry, + ), + }, + } + : state, + ); + } + const snapshot = yield* getSnapshot; + const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd); + const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? []; + return workspace + ? { ...snapshot, slashCommands: workspace.slashCommands, skills: resolvedSkills } + : { ...snapshot, skills: resolvedSkills }; + }); + + return { + snapshot: { ...managed, getSnapshot }, + onSessionStarted, + onConfigOptionsUpdated, + onAvailableCommands, + onSignedOut: clearAccountMetadata(), + onAuthRequired: clearAccountMetadata(), + snapshotForCwd, + }; +}); diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..27b503a3c2168001af5b4a3909e87544f2072820 --- /dev/null +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -0,0 +1,355 @@ +// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. +import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; +import { vi } from "vite-plus/test"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import { + ClaudeSettings, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +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 { + buildClaudeCapabilitiesProbeQueryOptions, + CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, + probeClaudeCapabilities, + probeClaudeWorkspaceSnapshot, +} from "./ClaudeProvider.ts"; +import { COMPACT_SLASH_COMMAND } from "./providerSnapshot.ts"; + +vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); + +const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); + +it("isolates Claude capability probes without dropping workspace setting sources", () => { + const abortController = new AbortController(); + const options = buildClaudeCapabilitiesProbeQueryOptions({ + executablePath: "/usr/bin/claude", + abortController, + environment: { + HOME: "/home/user", + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + FORCE_CODE_TERMINAL: "1", + }, + cwd: "/workspace/project", + }); + + assert.deepEqual(options.mcpServers, {}); + assert.equal(options.strictMcpConfig, true); + assert.equal(options.cwd, "/workspace/project"); + assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); + assert.deepEqual(options.settings, { disableAllHooks: true }); + assert.deepEqual(options.allowedTools, []); + assert.equal(options.persistSession, false); + assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude"); + assert.equal(options.abortController, abortController); + assert.equal(options.env?.HOME, "/home/user"); + assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); + assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined); + assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0"); + assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1"); +}); + +it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { + it.effect( + "discovers commands and skills separately for each cwd without replacing machine metadata", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")]; + for (const cwd of workspaces) { + const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString( + path.join(skillDir, "SKILL.md"), + "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", + ); + } + const machineSnapshot = { + instanceId: ProviderInstanceId.make("claude"), + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated", email: "machine@example.com" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "2.1.288", + models: [], + slashCommands: [{ name: "server-cwd-only" }], + skills: [], + } satisfies ServerProvider; + let usageCalls = 0; + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { + assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir); + assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance"); + return { + initializationResult: async () => ({ + account: { email: "workspace@example.com" }, + commands: [ + { + name: options?.cwd === workspaces[0] ? "start-session" : "other-project", + description: "Project command", + argumentHint: "[topic]", + }, + { name: "nested:review", description: "Review changes", argumentHint: "" }, + { name: "NESTED:review", description: "", argumentHint: "[path]" }, + { name: "compact", description: "Provider compact", argumentHint: "" }, + { name: "user-command", description: "Existing user command", argumentHint: "" }, + ], + }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { + usageCalls++; + return { rate_limits_available: false, rate_limits: null }; + }, + } as ReturnType; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + for (const [index, cwd] of workspaces.entries()) { + const scoped = yield* probeClaudeWorkspaceSnapshot( + decodeClaudeSettings({ homePath: configDir }), + machineSnapshot, + cwd, + { ...process.env, T3_WORKSPACE_PROBE: "owned-instance" }, + ); + assert.deepEqual(scoped, { + ...machineSnapshot, + slashCommandsPending: false, + slashCommands: [ + COMPACT_SLASH_COMMAND, + { + name: index === 0 ? "start-session" : "other-project", + description: "Project command", + input: { hint: "[topic]" }, + }, + { name: "nested:review", description: "Review changes", input: { hint: "[path]" } }, + { name: "user-command", description: "Existing user command" }, + ], + skills: [ + { + name: "existing-skill", + path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"), + enabled: true, + scope: "project", + description: "Existing project skill", + }, + ], + }); + } + assert.deepEqual( + query.mock.calls.map(([input]) => input.options?.cwd), + workspaces, + ); + assert.equal(usageCalls, 0); + }).pipe(Effect.scoped), + ); + + it.effect("keeps readable skills during failed command discovery and recovers on retry", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" }); + const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString( + path.join(skillDir, "SKILL.md"), + "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", + ); + const machineSnapshot = { + instanceId: ProviderInstanceId.make("claude"), + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "2.1.288", + models: [], + slashCommands: [{ name: "server-cwd-only" }], + skills: [], + } satisfies ServerProvider; + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ + initializationResult: () => + Promise.reject( + new Error("Initialization failed"), + ), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ + rate_limits_available: false, + rate_limits: null, + }), + }) as ReturnType, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const settings = decodeClaudeSettings({ homePath: cwd }); + const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); + assert.deepEqual(failed, { + ...machineSnapshot, + slashCommands: [COMPACT_SLASH_COMMAND], + slashCommandsPending: true, + skills: [ + { + name: "existing-skill", + path: path.join(skillDir, "SKILL.md"), + enabled: true, + scope: "project", + description: "Existing project skill", + }, + ], + }); + query.mockImplementation( + () => + ({ + initializationResult: async () => ({ + commands: [{ name: "recovered", description: "", argumentHint: "" }], + }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ + rate_limits_available: false, + rate_limits: null, + }), + }) as ReturnType, + ); + const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); + assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]); + assert.equal(recovered.status, "ready"); + assert.equal(recovered.slashCommandsPending, false); + assert.deepEqual(recovered.skills, failed.skills); + const disabled = yield* probeClaudeWorkspaceSnapshot( + { ...settings, enabled: false }, + machineSnapshot, + cwd, + ); + assert.equal(disabled, machineSnapshot); + assert.equal(query.mock.calls.length, 2); + }).pipe(Effect.scoped), + ); + + it.effect("serializes strict no-MCP options and still resolves account capabilities", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); + const executablePath = yield* path.fromFileUrl( + new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url), + ); + const invocationPath = path.join(tempDir, "invocation.json"); + // The probe aborts the SDK without awaiting the child's exit, and on + // Windows a directory that is still some process's cwd cannot be + // removed. Keep the workspace outside the scoped directory and let it + // go with a retrying removal once the child has gone. + const workspaceCwd = yield* fs.makeTempDirectory({ prefix: "t3-claude-probe-cwd-" }); + // Node's own retry rather than an Effect schedule: it.effect runs on a + // TestClock, so a scheduled retry would wait for time nobody advances. + // If the child still holds the directory after that, an empty temp + // directory is left behind rather than failing the test for it. + yield* Effect.addFinalizer(() => + Effect.promise(() => + NodeFSP.rm(workspaceCwd, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 250, + }).catch(() => undefined), + ), + ); + + const capabilities = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: executablePath }), + { + ...process.env, + T3_PROBE_INVOCATION_PATH: invocationPath, + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + workspaceCwd, + ); + + assert.deepEqual(capabilities, { + email: "dev@example.com", + subscriptionType: "pro", + tokenSource: "oauth", + apiProvider: undefined, + slashCommands: [ + { + name: "review", + description: "Review changes", + input: { hint: "[path]" }, + }, + ], + usage: { + rate_limits_available: true, + rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, + }, + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { + readonly args: ReadonlyArray; + readonly cwd: string; + readonly connectorEnv: string; + readonly mcpConfig: unknown; + }; + assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd)); + assert.equal(invocation.connectorEnv, "false"); + assert.equal(invocation.args.includes("--strict-mcp-config"), true); + assert.equal(invocation.args.includes("--mcp-config"), false); + assert.equal(invocation.mcpConfig, undefined); + + assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true); + + const settingsFlagIndex = invocation.args.indexOf("--settings"); + assert.notEqual(settingsFlagIndex, -1); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const flagSettings = JSON.parse(invocation.args[settingsFlagIndex + 1] ?? "{}") as { + readonly disableAllHooks?: boolean; + }; + assert.equal(flagSettings.disableAllHooks, true); + }).pipe(Effect.scoped), + ); +}); + +it.effect("preserves initialized capabilities when optional usage times out", () => + Effect.gen(function* () { + const usageStarted = yield* Deferred.make(); + let abortSignal: AbortSignal | undefined; + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { + abortSignal = options?.abortController?.signal; + return { + initializationResult: async () => ({ + account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], + }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => { + Deferred.doneUnsafe(usageStarted, Effect.void); + return new Promise(() => {}); + }, + } as ReturnType; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const probe = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: "claude" }), + ).pipe(Effect.forkChild); + yield* Deferred.await(usageStarted); + yield* TestClock.adjust("4 seconds"); + const capabilities = yield* Fiber.join(probe); + assert.equal(capabilities?.email, "dev@example.com"); + assert.equal(capabilities?.subscriptionType, "pro"); + assert.equal(capabilities?.tokenSource, "oauth"); + assert.deepEqual(capabilities?.slashCommands, [ + { name: "review", description: "Review changes", input: { hint: "[path]" } }, + ]); + assert.equal(capabilities?.usage, undefined); + assert.equal(abortSignal?.aborted, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..560982f974f1d925d1ed3c127a9155d7855938c9 --- /dev/null +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -0,0 +1,675 @@ +import { + type ClaudeSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderSlashCommand, + type ServerProviderResetCredits, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { + query as claudeQuery, + type Options as ClaudeQueryOptions, + type SlashCommand as ClaudeSlashCommand, + type SDKControlGetUsageResponse, + type SDKUserMessage, + type SettingSource, +} from "@anthropic-ai/claude-agent-sdk"; + +import { + buildServerProvider, + COMPACT_SLASH_COMMAND, + DEFAULT_TIMEOUT_MS, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "./providerSnapshot.ts"; +import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts"; +import { makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts"; +import { discoverClaudeSkills } from "./Drivers/ClaudeSkills.ts"; +import type { ProviderWorkspaceSnapshot } from "./ProviderDriver.ts"; +import { makeUnavailableUsageLimits } from "./providerUsageLimits.ts"; +import { + type ClaudeScopedLimitNames, + claudeUsageResponseToLimits, + recordClaudeUsageResponse, +} from "./claudeUsageLimits.ts"; +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + formatClaudeVersionUpgradeMessage, + resolveClaudeModelsForVersion, +} from "./ClaudeModelCatalog.ts"; + +const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const CLAUDE_PRESENTATION = { + displayName: "Claude", + showInteractionModeToggle: true, + reportsContextWindow: true, +} as const; +function toTitleCaseWords(value: string): string { + const parts: Array = []; + for (const part of value.split(/[\s_-]+/g)) { + if (part.length > 0) { + parts.push(part[0]!.toUpperCase() + part.slice(1).toLowerCase()); + } + } + return parts.join(" "); +} + +function claudeSubscriptionLabel(subscriptionType: string | undefined): string | undefined { + const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); + if (!normalized) return undefined; + + switch (normalized) { + case "claudemaxsubscription": + return "Max"; + case "claudemax5xsubscription": + return "Max 5x"; + case "claudemax20xsubscription": + return "Max 20x"; + case "claudeenterprisesubscription": + return "Enterprise"; + case "claudeteamsubscription": + return "Team"; + case "claudeprosubscription": + return "Pro"; + case "claudefreesubscription": + return "Free"; + case "max": + case "maxplan": + return "Max"; + case "max5": + return "Max 5x"; + case "max20": + return "Max 20x"; + case "enterprise": + return "Enterprise"; + case "team": + return "Team"; + case "pro": + return "Pro"; + case "free": + return "Free"; + default: + return toTitleCaseWords(subscriptionType!); + } +} + +function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined { + const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, ""); + if (!normalized) return undefined; + if ( + normalized === "apikey" || + normalized === "anthropicapikey" || + normalized === "anthropicauthtoken" + ) { + return "apiKey"; + } + return undefined; +} + +function formatClaudeSubscriptionAuthLabel(subscriptionType: string): string { + const subscriptionLabel = + claudeSubscriptionLabel(subscriptionType) ?? toTitleCaseWords(subscriptionType); + const normalized = subscriptionLabel.toLowerCase().replace(/[\s_-]+/g, ""); + + if (normalized.startsWith("claude") && normalized.endsWith("subscription")) { + return subscriptionLabel; + } + if (normalized.startsWith("claude")) { + return `${subscriptionLabel} Subscription`; + } + if (normalized.endsWith("subscription")) { + return `Claude ${subscriptionLabel}`; + } + return `Claude ${subscriptionLabel} Subscription`; +} + +function claudeAuthMetadata(input: { + readonly subscriptionType: string | undefined; + readonly authMethod: string | undefined; +}): { readonly type: string; readonly label: string } | undefined { + if (normalizeClaudeAuthMethod(input.authMethod) === "apiKey") { + return { + type: "apiKey", + label: "Claude API Key", + }; + } + + if (input.subscriptionType) { + return { + type: input.subscriptionType, + label: formatClaudeSubscriptionAuthLabel(input.subscriptionType), + }; + } + + return undefined; +} + +function apiProviderAuthMetadata( + apiProvider: string | undefined, +): { readonly type: string; readonly label: string } | undefined { + return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; +} + +// ── SDK capability probe ──────────────────────────────────────────── + +// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the +// Bedrock backend and runs the `awsAuthRefresh` credential hook before returning +// account info. The previous 8s budget expired mid-init, so the probe returned +// `undefined` and left the provider unverified and unselectable in the picker. +const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; + +/** + * Keep workspace-scoped command discovery intact while isolating the periodic + * health check from configured MCP servers. + */ +export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ + "user", + "project", + "local", +] as const satisfies ReadonlyArray; + +/** Build the exact SDK options used by the periodic Claude capability probe. */ +export function buildClaudeCapabilitiesProbeQueryOptions(input: { + readonly executablePath: string; + readonly abortController: AbortController; + readonly environment: NodeJS.ProcessEnv; + readonly cwd: string | undefined; +}): ClaudeQueryOptions { + return { + persistSession: false, + pathToClaudeCodeExecutable: input.executablePath, + abortController: input.abortController, + settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], + // The probe keeps filesystem setting sources for slash-command discovery, + // but must not run the user's hooks: it fires every few minutes, so + // SessionStart hooks would run on every health check. + settings: { disableAllHooks: true }, + allowedTools: [], + // Ignore MCP definitions from every filesystem setting source above. The + // SDK combines this empty explicit map with --strict-mcp-config. + mcpServers: {}, + strictMcpConfig: true, + env: { + ...input.environment, + // Connected claude.ai MCP servers are discovered outside filesystem + // config; disable them independently for this health check. + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + // This is a noninteractive health check, so IDE discovery cannot add any + // useful capability data. Skipping it also avoids Claude spawning a + // Windows `tasklist | findstr` process tree on every periodic refresh. + FORCE_CODE_TERMINAL: undefined, + CLAUDE_CODE_AUTO_CONNECT_IDE: "0", + CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", + }, + ...(input.cwd ? { cwd: input.cwd } : {}), + stderr: () => {}, + }; +} + +function nonEmptyProbeString(value: string): string | undefined { + const candidate = value.trim(); + return candidate ? candidate : undefined; +} + +type ClaudeCapabilitiesProbe = { + readonly email: string | undefined; + readonly subscriptionType: string | undefined; + readonly tokenSource: string | undefined; + /** + * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth + * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) + * the subscription/token fields are absent and auth is external AWS creds. + */ + readonly apiProvider: string | undefined; + readonly slashCommands: ReadonlyArray; + /** + * Subscription windows from the SDK's `get_usage` control request, or + * `undefined` when the request itself failed. Absent windows on an + * otherwise successful response mean the account has none (API key). + */ + readonly usage?: Pick; +}; + +function parseClaudeInitializationCommands( + commands: ReadonlyArray | undefined, +): ReadonlyArray { + return dedupeSlashCommands( + (commands ?? []).flatMap((command) => { + const name = nonEmptyProbeString(command.name); + if (!name) { + return []; + } + + const description = nonEmptyProbeString(command.description); + const argumentHint = nonEmptyProbeString(command.argumentHint); + + return [ + { + name, + ...(description ? { description } : {}), + ...(argumentHint ? { input: { hint: argumentHint } } : {}), + } satisfies ServerProviderSlashCommand, + ]; + }), + ); +} + +function dedupeSlashCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const commandsByName = new Map(); + + for (const command of commands) { + const name = nonEmptyProbeString(command.name); + if (!name) { + continue; + } + + const key = name.toLowerCase(); + const existing = commandsByName.get(key); + if (!existing) { + commandsByName.set(key, { + ...command, + name, + }); + continue; + } + + commandsByName.set(key, { + ...existing, + ...(existing.description + ? {} + : command.description + ? { description: command.description } + : {}), + ...(existing.input?.hint + ? {} + : command.input?.hint + ? { input: { hint: command.input.hint } } + : {}), + }); + } + + return [...commandsByName.values()]; +} + +function waitForAbortSignal(signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + +/** + * Probe account information by spawning a lightweight Claude Agent SDK + * session and reading the initialization result. + * + * We pass a never-yielding AsyncIterable as the prompt so that no user + * message is ever written to the subprocess stdin. This means the Claude + * Code subprocess completes its local initialization IPC (returning + * account info and slash commands) but never starts an API request to + * Anthropic. We read the init data and then abort the subprocess. + * + * This is used as a fallback when `claude auth status` does not include + * subscription type information. + */ +const probeClaudeCapabilities = ( + claudeSettings: ClaudeSettings, + environment?: NodeJS.ProcessEnv, + cwd?: string, + includeUsage = true, +) => { + const abort = new AbortController(); + return Effect.gen(function* () { + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const executablePath = yield* resolveClaudeSdkExecutablePath( + claudeSettings.binaryPath, + claudeEnvironment, + ); + return yield* Effect.tryPromise(async () => { + const q = claudeQuery({ + // Never yield — we only need initialization data, not a conversation. + // This prevents any prompt from reaching the Anthropic API. + // oxlint-disable-next-line require-yield + prompt: (async function* (): AsyncGenerator { + await waitForAbortSignal(abort.signal); + })(), + options: buildClaudeCapabilitiesProbeQueryOptions({ + executablePath, + abortController: abort, + environment: claudeEnvironment, + cwd, + }), + }); + const init = await q.initializationResult(); + return { q, init }; + }); + }).pipe( + Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), + Effect.flatMap(({ q, init }) => + Effect.gen(function* () { + // Usage has its own deadline so a slow optional request cannot discard initialization. + const usageResult = includeUsage + ? yield* Effect.tryPromise(() => + q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), + ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result) + : undefined; + const usage = + usageResult && Result.isSuccess(usageResult) + ? { + rate_limits_available: usageResult.success.rate_limits_available, + rate_limits: usageResult.success.rate_limits, + } + : undefined; + const account = init.account as + | { + readonly email?: string; + readonly subscriptionType?: string; + readonly tokenSource?: string; + readonly apiProvider?: string; + } + | undefined; + return { + email: account?.email, + subscriptionType: account?.subscriptionType, + tokenSource: account?.tokenSource, + apiProvider: account?.apiProvider, + slashCommands: parseClaudeInitializationCommands(init.commands), + ...(usage ? { usage } : {}), + } satisfies ClaudeCapabilitiesProbe; + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (!abort.signal.aborted) abort.abort(); + }), + ), + Effect.result, + Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)), + ); +}; + +const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( + claudeSettings: ClaudeSettings, + args: ReadonlyArray, + environment?: NodeJS.ProcessEnv, +) { + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { + env: claudeEnvironment, + }); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: claudeEnvironment, + shell: spawnCommand.shell, + }); + return yield* spawnAndCollect(claudeSettings.binaryPath, command); +}); + +/** Read commands from the same cwd Claude uses for a workspace session. */ +export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnapshot")(function* ( + claudeSettings: ClaudeSettings, + machineSnapshot: ServerProvider, + cwd: string, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + if (!claudeSettings.enabled) return machineSnapshot; + const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); + const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false); + return { + ...machineSnapshot, + skills, + slashCommands: dedupeSlashCommands([ + COMPACT_SLASH_COMMAND, + ...(capabilities?.slashCommands ?? []), + ]), + slashCommandsPending: !capabilities, + }; +}); + +export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( + claudeSettings: ClaudeSettings, + resolveCapabilities?: ( + claudeSettings: ClaudeSettings, + ) => Effect.Effect, + environment?: NodeJS.ProcessEnv, + cwd?: string, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, + /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ + scopedLimitNames?: Ref.Ref, + /** Banked resets for a subscription login, given the CLI version for the user agent. */ + resolveResetCredits?: (version: string) => Effect.Effect, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const resolvedEnvironment = environment ?? process.env; + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const allModels = providerModelsFromSettings( + modelCatalog.models.map((entry) => entry.model), + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); + + if (!claudeSettings.enabled) { + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: false, + checkedAt, + models: allModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Claude is disabled in T3 Code settings.", + }, + }); + } + + const versionProbe = yield* runClaudeCommand( + claudeSettings, + ["--version"], + resolvedEnvironment, + ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + yield* Effect.logWarning("Claude Agent CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: claudeSettings.enabled, + checkedAt, + models: allModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Claude Agent CLI (`claude`) was not found on PATH." + : "Failed to execute Claude Agent CLI health check.", + }, + }); + } + + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: claudeSettings.enabled, + checkedAt, + models: allModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: + "Claude Agent CLI is installed but failed to run. Timed out while running command.", + }, + }); + } + + const version = versionProbe.success.value; + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { + exitCode: version.code, + stdoutLength: version.stdout.length, + stderrLength: version.stderr.length, + }); + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: claudeSettings.enabled, + checkedAt, + models: allModels, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unknown" }, + message: "Claude Agent CLI is installed but failed to run.", + }, + }); + } + + const models = providerModelsFromSettings( + resolveClaudeModelsForVersion(modelCatalog, parsedVersion), + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); + const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); + + const capabilities = resolveCapabilities + ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) + : undefined; + const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); + const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])]; + const dedupedSlashCommands = dedupeSlashCommands(slashCommands); + + if (!capabilities) { + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: claudeSettings.enabled, + checkedAt, + models, + slashCommands: dedupedSlashCommands, + skills, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + auth: { status: "unknown" }, + message: "Could not verify Claude authentication status from initialization result.", + }, + }); + } + + const authMetadata = + claudeAuthMetadata({ + subscriptionType: capabilities.subscriptionType, + authMethod: capabilities.tokenSource, + }) ?? apiProviderAuthMetadata(capabilities.apiProvider); + const usageLimits = !capabilities.usage + ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) + : scopedLimitNames + ? yield* recordClaudeUsageResponse(scopedLimitNames, { + response: capabilities.usage, + checkedAt, + }) + : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits; + const resetCredits = + resolveResetCredits && + capabilities.subscriptionType && + !usageLimits.unavailable && + parsedVersion + ? yield* resolveResetCredits(parsedVersion) + : undefined; + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: claudeSettings.enabled, + checkedAt, + models, + slashCommands: dedupedSlashCommands, + skills, + probe: { + installed: true, + version: parsedVersion, + status: "ready", + auth: { + status: "authenticated", + ...(capabilities.email ? { email: capabilities.email } : {}), + ...(authMetadata ? authMetadata : {}), + }, + ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), + usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits, + }, + }); +}); + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +export const makePendingClaudeProvider = ( + claudeSettings: ClaudeSettings, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* nowIso; + const models = providerModelsFromSettings( + modelCatalog.models.map((entry) => entry.model), + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); + + if (!claudeSettings.enabled) { + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Claude is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Claude provider status has not been checked in this session yet.", + }, + }); + }); + +export { probeClaudeCapabilities }; diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index a97c2ac9d432689b29f0f790a7d02886def114d1..cf95d981d8a5df1e2c6a5e2b5a7c4c99ae1fe5b2 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -17,7 +17,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; -import { codexAppServerArgs } from "./Layers/codexLaunchArgs.ts"; +import { codexAppServerArgs } from "./codexLaunchArgs.ts"; import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c67b6f144dbb998716bcef4a30bde9de345e0a7 --- /dev/null +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -0,0 +1,173 @@ +import { assert, it } from "@effect/vitest"; + +import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; + +it("maps current Codex model capability fields", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + defaultReasoningEffort: "super-high", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + defaultServiceTier: "flex", + serviceTiers: [ + { + id: "priority", + name: "Fast", + description: "Lower latency responses.", + }, + { + id: "flex", + name: "Flex", + description: "Lower-cost asynchronous routing.", + }, + ], + supportedReasoningEfforts: [ + { + description: "Maximum reasoning", + reasoningEffort: "super-high", + }, + ], + }); + + assert.deepStrictEqual(capabilities.optionDescriptors, [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [{ id: "super-high", label: "super-high", isDefault: true }], + currentValue: "super-high", + }, + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard" }, + { + id: "priority", + label: "Fast", + description: "Lower latency responses.", + }, + { + id: "flex", + label: "Flex", + description: "Lower-cost asynchronous routing.", + isDefault: true, + }, + ], + currentValue: "flex", + }, + ]); +}); + +it("uses standard routing when the catalog has no default service tier", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: ["fast"], + defaultReasoningEffort: "medium", + defaultServiceTier: null, + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + serviceTiers: [ + { + id: "priority", + name: "Fast", + description: "1.5x speed, increased usage", + }, + { + id: "ultrafast", + name: "Ultrafast", + description: "The fastest available responses for latency-sensitive work.", + }, + ], + supportedReasoningEfforts: [], + }); + + assert.deepStrictEqual(capabilities.optionDescriptors, [ + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { + id: "priority", + label: "Fast", + description: "1.5x speed, increased usage", + }, + { + id: "ultrafast", + label: "Ultrafast", + description: "Even faster, more expensive", + }, + ], + currentValue: "default", + }, + ]); +}); + +it("marks the most preferred available model as default", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual( + models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), + [ + { slug: "gpt-5.6-terra", isDefault: true }, + { slug: "gpt-5.4", isDefault: undefined }, + ], + ); +}); + +it("prefers sol over terra when both are available", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); +}); + +it("ranks qualified Codex models while preserving their wire ids", () => { + const models = applyPreferredCodexDefaultModel([ + { + slug: "openai.gpt-5.6-luna", + name: "Luna", + isCustom: false, + isDefault: true, + capabilities: null, + }, + { slug: "openai.gpt-5.6-sol", name: "Sol", isCustom: false, capabilities: null }, + ]); + assert.deepStrictEqual( + models.filter((model) => model.isDefault).map((model) => model.slug), + ["openai.gpt-5.6-sol"], + ); +}); + +it("keeps Codex's own default when no preferred model is available", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); +}); + +it("ignores custom models that shadow a preferred slug", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); +}); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..979849e95cbcbf7002aceceeba61982e2c7f9e87 --- /dev/null +++ b/apps/server/src/provider/CodexProvider.ts @@ -0,0 +1,723 @@ +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 Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Types from "effect/Types"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as CodexClient from "effect-codex-app-server/client"; +import * as CodexSchema from "effect-codex-app-server/schema"; +import * as CodexErrors from "effect-codex-app-server/errors"; + +import type { + CodexSettings, + CustomModelSetting, + ServerProvider, + ServerProviderState, + ModelCapabilities, + ProviderOptionDescriptor, + ServerProviderModel, + ServerProviderSkill, +} from "@t3tools/contracts"; +import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; + +import { + codexModelFamily, + createModelCapabilities, + formatCodexModelName, + readCustomModelEntries, +} from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; +import { + AUTH_PROBE_TIMEOUT_MS, + buildServerProvider, + COMPACT_SLASH_COMMAND, + type ServerProviderDraft, +} from "./providerSnapshot.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import { makeUnavailableUsageLimits } from "./providerUsageLimits.ts"; +import { + codexRateLimitsFailureMessage, + codexRateLimitsToLimits, + type CodexRateLimitSnapshot, + type CodexResetCreditsSummary, +} from "./codexUsageLimits.ts"; +import packageJson from "../../package.json" with { type: "json" }; +const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); +const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; + +type CodexRateLimitsProbe = + | { + readonly snapshot: CodexRateLimitSnapshot; + readonly rateLimitsByLimitId?: + | Readonly> + | null + | undefined; + readonly resetCredits: CodexResetCreditsSummary | null | undefined; + } + | { readonly failure: string }; + +const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; + +const CODEX_PRESENTATION = { + displayName: "Codex", + showInteractionModeToggle: true, + reportsContextWindow: true, +} as const; + +export interface CodexAppServerProviderSnapshot { + readonly account: CodexSchema.V2GetAccountResponse; + readonly rateLimits?: CodexRateLimitsProbe; + readonly version: string | undefined; + readonly models: ReadonlyArray; + readonly skills: ReadonlyArray; +} + +const REASONING_EFFORT_LABELS: Readonly> = { + none: "None", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", + ultra: "Ultra", +}; + +const DEFAULT_SERVICE_TIER_ID = "default"; + +/** Shorter copy for tiers whose catalog description wraps in the traits menu. */ +const SERVICE_TIER_DESCRIPTIONS: Readonly> = { + ultrafast: "Even faster, more expensive", +}; + +function reasoningEffortLabel(reasoningEffort: string): string { + return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; +} + +function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) { + if (!account) return undefined; + if (account.type === "apiKey") return "OpenAI API Key"; + if (account.type === "amazonBedrock") return "Amazon Bedrock"; + if (account.type !== "chatgpt") return undefined; + return codexPlanLabel(account.planType); +} + +/** Shared with usage-limit sources, which report the same `planType` slugs. */ +export function codexPlanLabel(planType: string | null | undefined): string | undefined { + switch (planType) { + case "free": + return "ChatGPT Free Subscription"; + case "go": + return "ChatGPT Go Subscription"; + case "plus": + return "ChatGPT Plus Subscription"; + case "pro": + return "ChatGPT Pro 20x Subscription"; + case "prolite": + return "ChatGPT Pro 5x Subscription"; + case "promax": + return "ChatGPT Pro Max Subscription"; + case "team": + return "ChatGPT Team Subscription"; + case "self_serve_business_prolite": + case "self_serve_business_usage_based": + case "business": + return "ChatGPT Business Subscription"; + case "ent26": + case "enterprise_cbp_automation": + case "enterprise_cbp_usage_based": + case "enterprise": + return "ChatGPT Enterprise Subscription"; + case "edu": + case "edu_plus": + case "edu_pro": + return "ChatGPT Edu Subscription"; + case "unknown": + return "ChatGPT Subscription"; + default: + return undefined; + } +} + +function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) { + if (!account || account.type !== "chatgpt") return undefined; + return account.email; +} + +export function mapCodexModelCapabilities( + model: CodexSchema.V2ModelListResponse__Model, +): ModelCapabilities { + const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => + reasoningEffort === + (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort) + ? { + id: reasoningEffort, + label: reasoningEffortLabel(reasoningEffort), + isDefault: true, + } + : { + id: reasoningEffort, + label: reasoningEffortLabel(reasoningEffort), + }, + ); + const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; + const serviceTiers = + model.serviceTiers && model.serviceTiers.length > 0 + ? model.serviceTiers + : (model.additionalSpeedTiers ?? []).map((id) => ({ + id, + name: id === "fast" ? "Fast" : id, + description: "", + })); + const catalogDefaultServiceTier = serviceTiers.some( + (tier) => tier.id === model.defaultServiceTier, + ) + ? model.defaultServiceTier + : null; + const defaultServiceTier = catalogDefaultServiceTier ?? DEFAULT_SERVICE_TIER_ID; + const optionDescriptors: ProviderOptionDescriptor[] = []; + + if (reasoningOptions.length > 0) { + optionDescriptors.push({ + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: reasoningOptions, + ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), + }); + } + if (serviceTiers.length > 0) { + optionDescriptors.push({ + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { + id: DEFAULT_SERVICE_TIER_ID, + label: "Standard", + ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), + }, + ...serviceTiers.map((tier) => { + const description = SERVICE_TIER_DESCRIPTIONS[tier.id] ?? tier.description; + return { + id: tier.id, + label: tier.name, + ...(description ? { description } : {}), + ...(defaultServiceTier === tier.id ? { isDefault: true } : {}), + }; + }), + ], + currentValue: defaultServiceTier, + }); + } + + return createModelCapabilities({ + optionDescriptors, + }); +} + +function parseCodexModelListResponse( + response: CodexSchema.V2ModelListResponse, +): ReadonlyArray { + return response.data.map((model) => ({ + slug: model.model, + name: formatCodexModelName(model.displayName), + isCustom: false, + ...(model.isDefault ? { isDefault: true } : {}), + capabilities: mapCodexModelCapabilities(model), + })); +} + +/** + * Prefer our own default-model ranking when one of the preferred slugs is in + * the live catalog; otherwise keep whatever Codex itself flagged as default. + */ +export function applyPreferredCodexDefaultModel( + models: ReadonlyArray, +): ReadonlyArray { + const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) => + models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug), + )[0]?.slug; + if (!preferredSlug) { + return models; + } + return models.map((model) => { + if (model.slug === preferredSlug) { + return model.isDefault ? model : { ...model, isDefault: true }; + } + if (!model.isDefault) { + return model; + } + const { isDefault: _isDefault, ...rest } = model; + return rest; + }); +} + +/** + * Codex has no static default capability set, so a bare custom slug borrows + * the first built-in's descriptors; an entry with its own capabilities keeps + * them. + */ +function appendCustomCodexModels( + models: ReadonlyArray, + customModels: ReadonlyArray, +): ReadonlyArray { + if (customModels.length === 0) { + return models; + } + + const seen = new Set(models.map((model) => model.slug)); + const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; + const customEntries: ServerProviderModel[] = []; + for (const entry of readCustomModelEntries(customModels)) { + if (seen.has(entry.slug)) { + continue; + } + seen.add(entry.slug); + customEntries.push({ + slug: entry.slug, + name: entry.name, + isCustom: true, + capabilities: entry.capabilities ?? fallbackCapabilities, + }); + } + return customEntries.length === 0 ? models : [...models, ...customEntries]; +} + +function parseCodexSkillsListResponse( + response: CodexSchema.V2SkillsListResponse, + cwd: string, +): ReadonlyArray { + const matchingEntry = response.data.find((entry) => entry.cwd === cwd); + const skills = matchingEntry + ? matchingEntry.skills + : response.data.flatMap((entry) => entry.skills); + + return skills.map((skill) => { + const shortDescription = + skill.shortDescription ?? skill.interface?.shortDescription ?? undefined; + + const parsedSkill: Types.Mutable = { + name: skill.name, + path: skill.path, + enabled: skill.enabled, + }; + + if (skill.description) { + parsedSkill.description = skill.description; + } + if (skill.scope) { + parsedSkill.scope = skill.scope; + } + if (skill.interface?.displayName) { + parsedSkill.displayName = skill.interface.displayName; + } + if (shortDescription) { + parsedSkill.shortDescription = shortDescription; + } + + return parsedSkill; + }); +} + +const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( + client: CodexClient.CodexAppServerClient["Service"], +) { + const models: ServerProviderModel[] = []; + let cursor: string | null | undefined = undefined; + + do { + const response: CodexSchema.V2ModelListResponse = yield* client.request( + "model/list", + cursor ? { cursor } : {}, + ); + models.push(...parseCodexModelListResponse(response)); + cursor = response.nextCursor; + } while (cursor); + + return models; +}); + +export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { + return { + clientInfo: { + name: "T3 Code", + title: "T3 Code", + version: packageJson.version, + }, + capabilities: { + experimentalApi: true, + }, + }; +} + +/** + * Spawns a short-lived `codex app-server`, runs the initialize handshake, and + * hands the caller a connected client. Scoped: the process is killed when + * the caller's scope closes. Shared by the status probe, the skills probe, + * and account-level requests such as reset-credit redemption. + */ +export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string | undefined; + readonly launchArgs?: string | undefined; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv | undefined; +}) { + // `~` is not shell-expanded when env vars are set via `child_process.spawn`, + // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip + // "CODEX_HOME points to '~/.codex_work', but that path does not exist". + // Expand here for parity with `CodexTextGeneration`. + const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand( + input.binaryPath, + codexAppServerArgs(input.launchArgs), + { env: environment, extendEnv: true }, + ); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), + ); + const initialize = yield* client.request("initialize", buildCodexInitializeParams()); + yield* client.notify("initialized", undefined); + return { client, initialize }; +}); + +const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly customModels?: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; + readonly skipNativeUsage?: boolean; +}) { + const { client, initialize } = yield* withCodexAppServerClient(input); + + // Extract the version string after the first '/' in userAgent, up to the next space or the end + const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); + const version = versionMatch ? versionMatch[1] : undefined; + + const accountResponse = yield* client.request("account/read", {}); + if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { + return { + account: accountResponse, + version, + models: appendCustomCodexModels([], input.customModels ?? []), + skills: [], + } satisfies CodexAppServerProviderSnapshot; + } + + const [skillsResponse, models, rateLimits] = yield* Effect.all( + [ + client.request("skills/list", { + cwds: [input.cwd], + }), + requestAllCodexModels(client), + // Usage is an enrichment: a failure or a slow answer degrades to "no + // usage this probe" rather than costing the account and models. + input.skipNativeUsage + ? Effect.succeed(undefined) + : client.request("account/rateLimits/read", null).pipe( + Effect.map((response): CodexRateLimitsProbe => ({ + snapshot: response.rateLimits, + rateLimitsByLimitId: response.rateLimitsByLimitId, + resetCredits: response.rateLimitResetCredits, + })), + Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), + Effect.map( + Option.getOrElse((): CodexRateLimitsProbe => ({ + failure: "Codex did not answer the usage request.", + })), + ), + Effect.catch((error) => + Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( + Effect.as({ failure: codexRateLimitsFailureMessage(error) }), + ), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + return { + account: accountResponse, + ...(rateLimits ? { rateLimits } : {}), + version, + models: applyPreferredCodexDefaultModel( + appendCustomCodexModels(models, input.customModels ?? []), + ), + skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), + } satisfies CodexAppServerProviderSnapshot; +}); + +export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; +}) { + const { client } = yield* withCodexAppServerClient(input); + const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); + return parseCodexSkillsListResponse(skillsResponse, input.cwd); +}); + +const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => + appendCustomCodexModels([], codexSettings.customModels); + +const makePendingCodexProvider = ( + codexSettings: CodexSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = emptyCodexModelsFromSettings(codexSettings); + + if (!codexSettings.enabled) { + return buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: false, + checkedAt, + models, + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: true, + checkedAt, + models, + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex provider status has not been checked in this session yet.", + }, + }); + }); + +function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { + readonly status: Exclude; + readonly auth: ServerProvider["auth"]; + readonly message?: string; +} { + const authLabel = codexAccountAuthLabel(account.account); + const authEmail = codexAccountEmail(account.account); + const auth = { + status: account.account ? ("authenticated" as const) : ("unknown" as const), + ...(account.account?.type ? { type: account.account?.type } : {}), + ...(authLabel ? { label: authLabel } : {}), + ...(authEmail ? { email: authEmail } : {}), + } satisfies ServerProvider["auth"]; + + if (account.account) { + return { status: "ready", auth }; + } + + if (account.requiresOpenaiAuth) { + return { + status: "error", + auth: { status: "unauthenticated" }, + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }; + } + + return { status: "ready", auth }; +} + +export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( + codexSettings: CodexSettings, + probe: (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly customModels: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; + readonly skipNativeUsage?: boolean; + }) => Effect.Effect< + CodexAppServerProviderSnapshot, + CodexErrors.CodexAppServerError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + > = probeCodexAppServerProvider, + environment?: NodeJS.ProcessEnv, + managedAuth?: ServerProvider["auth"], +): Effect.fn.Return< + ServerProviderDraft, + ServerSettingsError, + ChildProcessSpawner.ChildProcessSpawner +> { + const resolvedEnvironment = environment ?? process.env; + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const emptyModels = emptyCodexModelsFromSettings(codexSettings); + + if (!codexSettings.enabled) { + return buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: false, + checkedAt, + models: emptyModels, + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex is disabled in T3 Code settings.", + }, + }); + } + + const probeResult = yield* probe({ + binaryPath: codexSettings.binaryPath, + homePath: codexSettings.homePath, + launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), + cwd: process.cwd(), + customModels: codexSettings.customModels, + environment: resolvedEnvironment, + ...(managedAuth ? { skipNativeUsage: true } : {}), + }).pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), + Effect.result, + ); + + if (Result.isFailure(probeResult)) { + const error = probeResult.failure; + const installed = !isCodexAppServerSpawnError(error); + return buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: codexSettings.enabled, + checkedAt, + models: emptyModels, + skills: [], + probe: { + installed, + version: null, + status: "error", + auth: { status: "unknown" }, + message: installed + ? `Codex app-server provider probe failed: ${error.message}.` + : `Could not start Codex CLI (\`${codexSettings.binaryPath}\`). Check Settings → Providers → Codex → Binary path on the server.` + + (codexSettings.binaryPath === "codex" + ? " Installing ChatGPT or Codex desktop may not add codex to PATH." + : " Make sure the configured executable exists and can be run."), + }, + }); + } + + if (Option.isNone(probeResult.success)) { + return buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: codexSettings.enabled, + checkedAt, + models: emptyModels, + skills: [], + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Timed out while checking Codex app-server provider status.", + }, + }); + } + + const snapshot = probeResult.success.value; + const accountStatus = managedAuth + ? { status: "ready" as const, auth: managedAuth, message: undefined } + : accountProbeStatus(snapshot.account); + const usageLimits = + snapshot.account.account?.type === "apiKey" + ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) + : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits + ? makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}), + }) + : codexRateLimitsToLimits({ + snapshot: snapshot.rateLimits.snapshot, + rateLimitsByLimitId: snapshot.rateLimits.rateLimitsByLimitId, + resetCredits: snapshot.rateLimits.resetCredits, + checkedAt, + }); + + return buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: codexSettings.enabled, + checkedAt, + models: snapshot.models, + skills: snapshot.skills, + slashCommands: [ + COMPACT_SLASH_COMMAND, + { + name: "goal", + description: "Set a goal Codex keeps working toward until it is done", + input: { hint: "Objective, or pause, resume, clear" }, + }, + { + name: "feedback", + description: "Send this thread and Codex logs to OpenAI", + input: { hint: "Describe the issue (optional)" }, + }, + ], + probe: { + installed: true, + version: snapshot.version ?? null, + status: accountStatus.status, + auth: accountStatus.auth, + ...(accountStatus.message ? { message: accountStatus.message } : {}), + ...(managedAuth ? {} : { usageLimits }), + }, + }); +}); + +// NOTE: the singleton `CodexProviderLive` Layer has been removed as part of +// the per-instance-driver refactor. `CodexDriver.create()` builds a managed +// snapshot per instance (each with its own `CodexSettings`) and hands the +// resulting `ServerProviderShape` back as `ProviderInstance.snapshot`. +// +// The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are +// re-exported for use by `CodexDriver`. +export { makePendingCodexProvider }; diff --git a/apps/server/src/provider/CursorAuth.ts b/apps/server/src/provider/CursorAuth.ts index cb372c00d5da483a094d7acc41cd1d8b78f5de6b..14988170b1eba759ea4fbf48b89cde3eba7d7776 100644 --- a/apps/server/src/provider/CursorAuth.ts +++ b/apps/server/src/provider/CursorAuth.ts @@ -17,7 +17,7 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Cursor, InMemoryCredentialStore } from "./cursorSdk.ts"; -import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; +import type { ProviderAuthController } from "./ProviderAuthService.ts"; const AUTH_TIMEOUT_MS = 300_000; diff --git a/apps/server/src/provider/CursorProvider.test.ts b/apps/server/src/provider/CursorProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7fe35db9ea324bf85c965361aa260bd8d88012b7 --- /dev/null +++ b/apps/server/src/provider/CursorProvider.test.ts @@ -0,0 +1,252 @@ +import type { SDKModel } from "@cursor/sdk"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type { CursorSettings } from "@t3tools/contracts"; +import { CursorSettings as CursorSettingsSchema } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildCursorCapabilitiesFromSdkModel, + buildCursorDiscoveredModelsFromSdk, + buildCursorProviderSnapshot, + buildInitialCursorProviderSnapshot, + checkCursorProviderStatus, +} from "./CursorProvider.ts"; +import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; + +const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema); + +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string, currentValue?: boolean) { + return { + id, + label, + type: "boolean" as const, + ...(typeof currentValue === "boolean" ? { currentValue } : {}), + }; +} + +const baseCursorSettings: CursorSettings = decodeCursorSettings({ + enabled: true, + customModels: [], +}); + +const sdkParameterizedModel = { + id: "claude-opus-4-8", + displayName: "Opus 4.8", + parameters: [ + { + id: "thinking", + displayName: "Thinking", + values: [{ value: "false" }, { value: "true" }], + }, + { + id: "context", + displayName: "Context", + values: [ + { value: "300k", displayName: "300K" }, + { value: "1m", displayName: "1M" }, + ], + }, + { + id: "effort", + displayName: "Effort", + values: [ + { value: "low", displayName: "Low" }, + { value: "high", displayName: "High" }, + ], + }, + { + id: "fast", + displayName: "Fast", + values: [{ value: "false" }, { value: "true", displayName: "Fast" }], + }, + ], + variants: [ + { + displayName: "Opus 4.8", + isDefault: true, + params: [ + { id: "thinking", value: "true" }, + { id: "context", value: "1m" }, + { id: "effort", value: "high" }, + { id: "fast", value: "false" }, + ], + }, + ], +} satisfies SDKModel; + +describe("buildInitialCursorProviderSnapshot", () => { + it.effect("uses SDK-specific pending status copy", () => + Effect.gen(function* () { + const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings); + + expect(provider).toMatchObject({ + status: "warning", + message: "Checking Cursor SDK availability...", + }); + }), + ); +}); + +describe("buildCursorProviderSnapshot", () => { + it("downgrades ready status to warning when SDK model discovery returns no models", () => { + expect( + buildCursorProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + cursorSettings: baseCursorSettings, + parsed: { + version: null, + status: "ready", + auth: { status: "authenticated", type: "api-key", label: "Cursor API key" }, + }, + discoveryWarning: "Cursor SDK model discovery returned no built-in models.", + }), + ).toMatchObject({ + status: "warning", + message: "Cursor SDK model discovery returned no built-in models.", + models: [], + supportsConversationRollback: false, + }); + }); +}); + +describe("Cursor SDK model discovery", () => { + it("maps native SDK parameter ids and default variant values to model capabilities", () => { + expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("effort", "Effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ]), + selectDescriptor("contextWindow", "Context", [ + { id: "300k", label: "300K" }, + { id: "1m", label: "1M", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast", false), + booleanDescriptor("thinking", "Thinking", true), + ], + }), + ); + }); + + it("filters invalid and duplicate SDK model entries", () => { + expect( + buildCursorDiscoveredModelsFromSdk([ + sdkParameterizedModel, + { ...sdkParameterizedModel, displayName: "Duplicate" }, + { id: "", displayName: "Invalid" }, + ]), + ).toEqual([ + { + slug: "claude-opus-4-8", + name: "Opus 4.8", + isCustom: false, + capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel), + }, + ]); + }); +}); + +describe("checkCursorProviderStatus", () => { + it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () => + Effect.gen(function* () { + const provider = yield* checkCursorProviderStatus( + { + ...baseCursorSettings, + customModels: ["internal/cursor-model"], + }, + { CURSOR_API_KEY: "test-cursor-key" }, + ).pipe( + Effect.provide( + CursorSdkCatalog.layerTest((apiKey) => { + expect(apiKey).toBe("test-cursor-key"); + return Effect.succeed({ + user: { + apiKeyName: "test-key", + userEmail: "cursor@example.com", + createdAt: "2026-01-01T00:00:00.000Z", + }, + models: [sdkParameterizedModel], + }); + }), + ), + ); + + expect(provider).toMatchObject({ + status: "ready", + auth: { + status: "authenticated", + type: "api-key", + label: "Cursor API key (test-key)", + email: "cursor@example.com", + }, + models: [ + { slug: "claude-opus-4-8", isCustom: false }, + { slug: "internal/cursor-model", isCustom: true }, + ], + }); + }), + ); + + it.effect("surfaces SDK authentication failures", () => + Effect.gen(function* () { + const provider = yield* checkCursorProviderStatus(baseCursorSettings, { + CURSOR_API_KEY: "invalid-test-key", + }).pipe( + Effect.provide( + CursorSdkCatalog.layerTest(() => + Effect.fail( + new CursorSdkCatalog.CursorSdkCatalogError({ + authenticationFailure: true, + cause: new Error("unauthorized"), + }), + ), + ), + ), + ); + + expect(provider).toMatchObject({ + status: "error", + auth: { status: "unauthenticated" }, + message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.", + }); + }), + ); + + it.effect("requires a Cursor API key without probing any external Cursor binary", () => + Effect.gen(function* () { + const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe( + Effect.provide( + CursorSdkCatalog.layerTest(() => + Effect.die("SDK catalog must not be used without CURSOR_API_KEY"), + ), + ), + ); + + expect(provider).toMatchObject({ + installed: true, + status: "error", + auth: { status: "unauthenticated" }, + message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", + }); + }), + ); +}); diff --git a/apps/server/src/provider/CursorProvider.ts b/apps/server/src/provider/CursorProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c0ab4d2e868df09d21fc2e5386134d2595f79ea --- /dev/null +++ b/apps/server/src/provider/CursorProvider.ts @@ -0,0 +1,350 @@ +import type { SDKModel, SDKUser } from "@cursor/sdk"; +import type { + CursorSettings, + ModelCapabilities, + ProviderOptionDescriptor, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; + +import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./cursorSdkModel.ts"; +import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, + buildServerProvider, + providerModelsFromSettings, + type ServerProviderDraft, +} from "./providerSnapshot.ts"; +import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; + +const CURSOR_PRESENTATION = { + displayName: "Cursor", + supportsConversationRollback: false, + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000; + +export function buildInitialCursorProviderSnapshot( + cursorSettings: CursorSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = getCursorFallbackModels(cursorSettings); + + if (!cursorSettings.enabled) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cursor is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Cursor SDK availability...", + }, + }); + }); +} + +function getCursorFallbackModels( + cursorSettings: Pick, +): ReadonlyArray { + return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); +} + +function toTitleCaseWords(value: string): string { + const parts: Array = []; + for (const part of value.split(/[\s_-]+/g)) { + if (part.length > 0) { + parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()); + } + } + return parts.join(" "); +} + +function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined { + return model.variants + ?.find((variant) => variant.isDefault) + ?.params.find((parameter) => parameter.id === parameterId)?.value; +} + +export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities { + const seen = new Set(); + const optionDescriptors: Array = []; + const parameters = (model.parameters ?? []) + .map((parameter, index) => ({ parameter, index })) + .toSorted( + (left, right) => + cursorSdkParameterPriority(left.parameter.id) - + cursorSdkParameterPriority(right.parameter.id) || left.index - right.index, + ); + for (const { parameter } of parameters) { + const nativeId = parameter.id.trim(); + const id = cursorSdkProviderOptionId(nativeId); + if (!nativeId || !id || seen.has(id)) { + continue; + } + seen.add(id); + + const values = parameter.values.flatMap((entry) => { + const value = entry.value.trim(); + if (!value) { + return []; + } + return [ + { + value, + label: entry.displayName?.trim() || value, + }, + ]; + }); + if (values.length === 0) { + continue; + } + + const label = parameter.displayName?.trim() || toTitleCaseWords(id); + const defaultValue = cursorSdkDefaultParameterValue(model, nativeId); + const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase())); + if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) { + if (defaultValue === "true" || defaultValue === "false") { + optionDescriptors.push( + buildBooleanOptionDescriptor({ + id, + label, + currentValue: defaultValue === "true", + }), + ); + } else { + optionDescriptors.push(buildBooleanOptionDescriptor({ id, label })); + } + continue; + } + + optionDescriptors.push( + buildSelectOptionDescriptor({ + id, + label, + options: values.map((entry) => ({ + ...entry, + ...(entry.value === defaultValue ? { isDefault: true } : {}), + })), + }), + ); + } + + return createModelCapabilities({ optionDescriptors }); +} + +export function buildCursorDiscoveredModelsFromSdk( + models: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return models.flatMap((model) => { + const slug = model.id.trim(); + const name = model.displayName.trim(); + if (!slug || !name || seen.has(slug)) { + return []; + } + seen.add(slug); + return [ + { + slug, + name, + isCustom: false, + capabilities: buildCursorCapabilitiesFromSdkModel(model), + } satisfies ServerProviderModel, + ]; + }); +} + +function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth { + const email = user.userEmail?.trim(); + const apiKeyName = user.apiKeyName.trim(); + return { + status: "authenticated", + type, + label: + type === "browser" + ? "Cursor account" + : apiKeyName + ? `Cursor API key (${apiKeyName})` + : "Cursor API key", + ...(email ? { email } : {}), + }; +} + +interface CursorProviderProbeResult { + readonly version: string | null; + readonly status: Exclude; + readonly auth: ServerProviderAuth; + readonly message?: string; +} + +function joinProviderMessages(...messages: ReadonlyArray): string | undefined { + const parts: Array = []; + for (const message of messages) { + const trimmed = message?.trim(); + if (trimmed) { + parts.push(trimmed); + } + } + return parts.length > 0 ? parts.join(" ") : undefined; +} + +export function buildCursorProviderSnapshot(input: { + readonly checkedAt: string; + readonly cursorSettings: CursorSettings; + readonly parsed: CursorProviderProbeResult; + readonly discoveredModels?: ReadonlyArray; + readonly discoveryWarning?: string; +}): ServerProviderDraft { + const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: input.cursorSettings.enabled, + checkedAt: input.checkedAt, + models: providerModelsFromSettings( + input.discoveredModels ?? [], + input.cursorSettings.customModels, + EMPTY_CAPABILITIES, + ), + probe: { + installed: true, + version: input.parsed.version, + status: + input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, + auth: input.parsed.auth, + ...(message ? { message } : {}), + }, + }); +} + +export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( + cursorSettings: CursorSettings, + environment?: NodeJS.ProcessEnv, + authenticationType: "api-key" | "browser" = "api-key", +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = getCursorFallbackModels(cursorSettings); + + if (!cursorSettings.enabled) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cursor is disabled in T3 Code settings.", + }, + }); + } + + const sdkApiKey = environment?.CURSOR_API_KEY?.trim(); + if (!sdkApiKey) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unauthenticated" }, + message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", + }, + }); + } + + const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog; + const catalogResult = yield* sdkCatalog + .read(sdkApiKey) + .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(catalogResult)) { + yield* Effect.logWarning("Cursor SDK catalog probe failed", { + cause: catalogResult.failure.cause, + }); + const authenticationFailure = catalogResult.failure.authenticationFailure; + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: authenticationFailure ? "unauthenticated" : "unknown" }, + message: authenticationFailure + ? authenticationType === "browser" + ? "Cursor sign-in expired or was rejected. Sign in again in provider settings." + : "Cursor SDK authentication failed. Check CURSOR_API_KEY." + : "Cursor SDK catalog request failed. Check server logs for details.", + }, + }); + } + + if (Option.isNone(catalogResult.success)) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`, + }, + }); + } + + const snapshot = catalogResult.success.value; + const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models); + return buildCursorProviderSnapshot({ + checkedAt, + cursorSettings, + parsed: { + version: null, + status: "ready", + auth: cursorSdkAuth(snapshot.user, authenticationType), + }, + discoveredModels, + ...(discoveredModels.length === 0 + ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." } + : {}), + }); +}); diff --git a/apps/server/src/provider/CursorSdkCatalog.test.ts b/apps/server/src/provider/CursorSdkCatalog.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cbd24f8b6be705b93f1cd4a48799b695587ded5 --- /dev/null +++ b/apps/server/src/provider/CursorSdkCatalog.test.ts @@ -0,0 +1,66 @@ +import type { SDKModel, SDKUser } from "@cursor/sdk"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; + +const user = { + apiKeyName: "test-key", + userEmail: "cursor@example.com", + createdAt: "2026-01-01T00:00:00.000Z", +} satisfies SDKUser; + +const model = { + id: "claude-opus-4-8", + displayName: "Opus 4.8", + parameters: [], +} satisfies SDKModel; + +const catalogError = () => + new CursorSdkCatalog.CursorSdkCatalogError({ + authenticationFailure: false, + cause: new Error("catalog unavailable"), + }); + +describe("CursorSdkCatalog", () => { + it.effect("keeps the user probe fresh while caching models by API key", () => + Effect.gen(function* () { + let userCalls = 0; + let modelCalls = 0; + const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ + readUser: () => Effect.sync(() => ((userCalls += 1), user)), + readModels: () => Effect.sync(() => ((modelCalls += 1), [model])), + }); + + yield* catalog.read("first-key"); + yield* catalog.read("first-key"); + yield* catalog.read("second-key"); + + expect(userCalls).toBe(3); + expect(modelCalls).toBe(2); + }), + ); + + it.effect("does not cache failed or empty model discovery", () => + Effect.gen(function* () { + let modelCalls = 0; + const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ + readUser: () => Effect.succeed(user), + readModels: () => + Effect.suspend(() => { + modelCalls += 1; + if (modelCalls === 1) return Effect.fail(catalogError()); + if (modelCalls === 2) return Effect.succeed([]); + return Effect.succeed([model]); + }), + }); + + expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true); + expect((yield* catalog.read("test-key")).models).toEqual([]); + expect((yield* catalog.read("test-key")).models).toEqual([model]); + expect((yield* catalog.read("test-key")).models).toEqual([model]); + expect(modelCalls).toBe(3); + }), + ); +}); diff --git a/apps/server/src/provider/CursorSdkCatalog.ts b/apps/server/src/provider/CursorSdkCatalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..e866955dd55f10bb60311435d9869f422ff04fce --- /dev/null +++ b/apps/server/src/provider/CursorSdkCatalog.ts @@ -0,0 +1,92 @@ +import type { SDKModel, SDKUser } from "@cursor/sdk"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { AuthenticationError, Cursor, CursorSdkError } from "./cursorSdk.ts"; + +export interface CursorSdkCatalogSnapshot { + readonly user: SDKUser; + readonly models: ReadonlyArray; +} + +export class CursorSdkCatalogError extends Schema.TaggedError()( + "CursorSdkCatalogError", + { + authenticationFailure: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return this.authenticationFailure + ? "Cursor SDK authentication failed." + : "Cursor SDK catalog request failed."; + } +} + +export interface CursorSdkCatalogShape { + readonly read: (apiKey: string) => Effect.Effect; +} + +export class CursorSdkCatalog extends Context.Service()( + "t3/provider/CursorSdkCatalog", +) {} + +function isAuthenticationFailure(cause: unknown): boolean { + return ( + cause instanceof AuthenticationError || + (cause instanceof CursorSdkError && cause.status === 401) + ); +} + +export interface CursorSdkCatalogProbes { + readonly readUser: (apiKey: string) => Effect.Effect; + readonly readModels: ( + apiKey: string, + ) => Effect.Effect, CursorSdkCatalogError>; +} + +const sdkRequest = (tryRequest: () => Promise) => + Effect.tryPromise({ + try: tryRequest, + catch: (cause) => + new CursorSdkCatalogError({ + authenticationFailure: isAuthenticationFailure(cause), + cause, + }), + }); + +const liveProbes: CursorSdkCatalogProbes = { + readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })), + readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })), +}; + +export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* ( + probes: CursorSdkCatalogProbes = liveProbes, +) { + const modelCache = yield* Cache.makeWith(probes.readModels, { + capacity: 32, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, + }); + return CursorSdkCatalog.of({ + read: (apiKey) => + Effect.all( + { + user: probes.readUser(apiKey), + models: Cache.get(modelCache, apiKey), + }, + { concurrency: "unbounded" }, + ), + }); +}); + +export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog()); + +export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer { + return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read })); +} diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 650d8d875297fe5ae24d57fcd63f06d283447659..5e0581f5bdd3ecbc566338654475aa43886eabb1 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -29,7 +29,7 @@ import { ANTIGRAVITY_AUTH_STDOUT_PREFIX, resolveAntigravityInstanceDirectories, } from "../antigravityAuthSupport.ts"; -import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; import { AntigravityDriver } from "./AntigravityDriver.ts"; diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 248cf2199a14ce1149248c55ed61388930caee28..7a10b4a725cdd55142fa06000719443a32805746 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -52,8 +52,8 @@ import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderCo import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { ProviderDriverError } from "../Errors.ts"; -import { makeAntigravityProvider } from "../Layers/AntigravityProvider.ts"; -import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import { makeAntigravityProvider } from "../AntigravityProvider.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { defaultProviderContinuationIdentity, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 01f755586d3d90cd9b48f09de5c4dbef8a57da0a..4f32cf9d2b5aae262fa58160635e239aa5752ef6 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -27,22 +27,22 @@ import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGenerat import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "../../pathExpansion.ts"; -import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { createClaudeAdapterV2, type ClaudeAdapterV2DriverEnv, } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import * as ServerSettings from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; -import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts"; -import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts"; -import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; +import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts"; +import * as ClaudeResetCredits from "../claudeResetCredits.ts"; +import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; import { checkClaudeProviderStatus, makePendingClaudeProvider, probeClaudeCapabilities, probeClaudeWorkspaceSnapshot, -} from "../Layers/ClaudeProvider.ts"; +} from "../ClaudeProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index a2740d6abbd6b2a99e3852f0319fa483b22ee4cd..f732d6d5bba53036298a1eb5913d7b1a6c7b6a86 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -25,8 +25,8 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; -import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; -import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { createProviderVersionAdvisory, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 927ec4da42eb5e9f2e3215717ddbf968ac583d86..0b958e2978a5f8f12ba09c53ad68794049d52e5c 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -34,21 +34,21 @@ import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneratio import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "../../pathExpansion.ts"; -import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { createCodexAdapterV2, type CodexAdapterV2DriverEnv, } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as ServerSettings from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; -import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; +import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; import { checkCodexProviderStatus, makePendingCodexProvider, probeCodexSkillsForCwd, withCodexAppServerClient, -} from "../Layers/CodexProvider.ts"; -import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; +} from "../CodexProvider.ts"; +import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index 7c7f7d6ab979db8af2256123e1649ace42795a25..b75e1edd5414e9e26822f21d1903dd887ccc811b 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -13,7 +13,7 @@ import { checkCodexProviderStatus, makePendingCodexProvider, probeCodexSkillsForCwd, -} from "../Layers/CodexProvider.ts"; +} from "../CodexProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { type ProviderDriverCreateInput, type ProviderInstance } from "../ProviderDriver.ts"; diff --git a/apps/server/src/provider/Drivers/CursorDriver.test.ts b/apps/server/src/provider/Drivers/CursorDriver.test.ts index aa2839ea687347d45ec59995275bdcbc8d7976a3..b0f9c2ad0d4a374e4ece4ea4fd94d984c7ce8dbc 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.test.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.test.ts @@ -14,7 +14,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; -import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { CursorDriver } from "./CursorDriver.ts"; import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 40a696650c4013b06cf47e3e2cf56511e23e3391..f0f55a8cb34801d675aab0f99d032aaa9a422f30 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -13,7 +13,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; -import { readCursorUsageLimits } from "../Layers/cursorUsageLimits.ts"; +import { readCursorUsageLimits } from "../cursorUsageLimits.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; @@ -27,8 +27,8 @@ import { ProviderDriverError } from "../Errors.ts"; import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus, -} from "../Layers/CursorProvider.ts"; -import * as CursorSdkCatalog from "../Layers/CursorSdkCatalog.ts"; +} from "../CursorProvider.ts"; +import * as CursorSdkCatalog from "../CursorSdkCatalog.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, diff --git a/apps/server/src/provider/Drivers/GrokDriver.test.ts b/apps/server/src/provider/Drivers/GrokDriver.test.ts index d5eb0f54bc88d98735ac782d3352813108aa7a19..f6d70d1a3476283bdc2f3a119b4ecf890770a8c4 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.test.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.test.ts @@ -12,7 +12,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; -import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { GrokDriver } from "./GrokDriver.ts"; import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 03b6e6f4f37c7f93afaa5314f19882c30251368b..08ca7d5c715f6ce0100fbeeafedc1a1844d1e09b 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -9,7 +9,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; -import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import * as ServerSettings from "../../serverSettings.ts"; import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; import { @@ -21,8 +21,8 @@ import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus, enrichGrokSnapshot, -} from "../Layers/GrokProvider.ts"; -import { readGrokAccount } from "../Layers/grokUsageLimits.ts"; +} from "../GrokProvider.ts"; +import { readGrokAccount } from "../grokUsageLimits.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts index 61040aa3c764884ca5b325bb52dc4ee77349f59f..c1989ee335a5e46ffee35985c3378adb155bd4de 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts @@ -18,7 +18,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; import * as ServerSettings from "../../serverSettings.ts"; -import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import * as ProviderMaintenance from "../providerMaintenance.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 4c3fb6c82475d59a769f26ae46704faf6d3da708..e84e265b8c53fba86296f637414b2ed2c2c2a07e 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -34,7 +34,7 @@ import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdap import * as ServerSettings from "../../serverSettings.ts"; import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; -import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts"; +import { readOpenCodeGoUsageLimits } from "../openCodeUsageLimits.ts"; import { checkOpenCodeProviderStatus, loadOpenCode2Workspace, @@ -44,7 +44,7 @@ import { openCode2SkillsToServerProviderSkills, openCodeSkillsToServerProviderSkills, openCodeCommandsToServerProviderSlashCommands, -} from "../Layers/OpenCodeProvider.ts"; +} from "../OpenCodeProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts index 685f6efbaaef73b1f5821ffeb68bbceae867bec9..07c33753874a5f8424b929c516f238b3217da781 100644 --- a/apps/server/src/provider/Drivers/PiDriver.ts +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -27,7 +27,7 @@ import { buildInitialPiProviderSnapshot, checkPiProviderStatus, enrichPiSnapshot, -} from "../Layers/PiProvider.ts"; +} from "../PiProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, diff --git a/apps/server/src/provider/EventNdjsonLogger.test.ts b/apps/server/src/provider/EventNdjsonLogger.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f150596f65ece0f958d224b756ec87470625cf17 --- /dev/null +++ b/apps/server/src/provider/EventNdjsonLogger.test.ts @@ -0,0 +1,856 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { ThreadId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Logger from "effect/Logger"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; +import { + makeEventNdjsonLogger, + makeEventNdjsonLogStore, + type PendingRecord, + writeBatchedMessages, +} from "./EventNdjsonLogger.ts"; + +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function ownedLogPath(basePath: string, segment: string): string { + const basename = NodePath.basename(basePath); + const extension = NodePath.extname(basename); + const stem = extension.length > 0 ? basename.slice(0, -extension.length) : basename; + return NodePath.join(NodePath.dirname(basePath), `${stem}.${segment}.log`); +} + +function parseLogLine(line: string) { + const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); + assert.notEqual(match, null); + if (!match) { + throw new Error(`invalid log line: ${line}`); + } + const observedAt = match[1]; + const stream = match[2]; + const payload = match[3]; + if (!observedAt || !stream || payload === undefined) { + throw new Error(`invalid log line: ${line}`); + } + return { + observedAt, + stream, + payload, + }; +} + +describe("EventNdjsonLogger", () => { + it.effect("summarizes circular events without exposing their contents in diagnostics", () => { + const messages: Array = []; + const logCapture = Logger.make(({ message }) => { + if (Array.isArray(message)) { + messages.push(...message); + } else { + messages.push(message); + } + }); + const secret = "secret-circular-event-value"; + + return Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); + const circular: Record = { secret }; + circular.self = circular; + + try { + const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); + assert.exists(logger); + if (!logger) return; + yield* logger.write(circular, ThreadId.make("thread-1")); + yield* logger.close(); + + const serialized = encodeUnknownJson(messages); + assert.notInclude(serialized, secret); + const line = parseLogLine( + NodeFS.readFileSync(ownedLogPath(basePath, "thread-1"), "utf8").trim(), + ); + assert.equal(line.payload, '{"truncated":true}'); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); + }); + + it.effect("writes effect-style lines to thread-scoped files", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); + + try { + const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); + assert.notEqual(logger, undefined); + if (!logger) { + return; + } + + yield* logger.write( + { threadId: "provider-thread-1", id: "evt-1" }, + ThreadId.make("thread-1"), + ); + yield* logger.write( + { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" }, + ThreadId.make("thread-2"), + ); + yield* logger.close(); + + const threadOnePath = ownedLogPath(basePath, "thread-1"); + const threadTwoPath = ownedLogPath(basePath, "thread-2"); + assert.equal(NodeFS.existsSync(threadOnePath), true); + assert.equal(NodeFS.existsSync(threadTwoPath), true); + + const first = parseLogLine(NodeFS.readFileSync(threadOnePath, "utf8").trim()); + const second = parseLogLine(NodeFS.readFileSync(threadTwoPath, "utf8").trim()); + + assert.equal(Number.isNaN(Date.parse(first.observedAt)), false); + assert.equal(first.stream, "NTIVE"); + assert.equal(first.payload, '{"threadId":"provider-thread-1","id":"evt-1"}'); + + assert.equal(Number.isNaN(Date.parse(second.observedAt)), false); + assert.equal(second.stream, "NTIVE"); + assert.equal( + second.payload, + '{"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}', + ); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect( + "falls back to a global segment when orchestration thread id is missing or invalid", + () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); + + try { + const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" }); + assert.notEqual(logger, undefined); + if (!logger) { + return; + } + + yield* logger.write({ id: "evt-no-thread" }, null); + yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); + yield* logger.close(); + + const globalPath = ownedLogPath(basePath, "_global"); + assert.equal(NodeFS.existsSync(globalPath), true); + const lines = NodeFS.readFileSync(globalPath, "utf8") + .trim() + .split("\n") + .map((line) => parseLogLine(line)); + assert.equal(lines.length, 2); + assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false); + assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false); + assert.equal(lines[0]?.stream, "ORCH"); + assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}'); + assert.equal(lines[1]?.stream, "ORCH"); + assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("shares one thread writer across native and canonical streams", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); + const native = store.logger("native"); + const canonical = store.logger("canonical"); + const threadId = ThreadId.make("thread-shared"); + + yield* native.write({ id: "native-event" }, threadId); + yield* canonical.write({ type: "item.completed", id: "canonical-event" }, threadId); + yield* store.close(); + + const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared"), "utf8") + .trim() + .split("\n") + .map(parseLogLine); + + assert.deepEqual( + lines.map(({ stream, payload }) => ({ stream, payload })), + [ + { stream: "NTIVE", payload: '{"id":"native-event"}' }, + { + stream: "CANON", + payload: '{"type":"item.completed","id":"canonical-event"}', + }, + ], + ); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("keeps shared store views non-owning when one adapter closes", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); + const native = store.logger("native"); + const canonical = store.logger("canonical"); + const threadId = ThreadId.make("thread-shared-close"); + + yield* native.write({ id: "before-close" }, threadId); + yield* native.close(); + yield* canonical.write({ type: "item.completed", id: "after-close" }, threadId); + yield* store.close(); + + const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared-close"), "utf8") + .trim() + .split("\n") + .map(parseLogLine); + + assert.deepEqual( + lines.map(({ stream, payload }) => ({ stream, payload })), + [ + { stream: "NTIVE", payload: '{"id":"before-close"}' }, + { + stream: "CANON", + payload: '{"type":"item.completed","id":"after-close"}', + }, + ], + ); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("flushes an active batch without a permanent polling loop", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const threadPath = ownedLogPath(basePath, "thread-batched"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); + yield* store + .logger("native") + .write({ id: "batched-event" }, ThreadId.make("thread-batched")); + + assert.equal(NodeFS.existsSync(threadPath), false); + yield* TestClock.adjust(1_000); + assert.equal(NodeFS.existsSync(threadPath), true); + yield* store.close(); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("does not strand a later batch after an interrupted write", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const threadPath = ownedLogPath(basePath, "thread-interrupted"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); + const logger = store.logger("native"); + const interruptedWrite = yield* logger + .write({ id: "possibly-interrupted" }, ThreadId.make("thread-interrupted")) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Fiber.interrupt(interruptedWrite); + yield* logger.write({ id: "accepted" }, ThreadId.make("thread-interrupted")); + + yield* TestClock.adjust(1_000); + + assert.equal(NodeFS.existsSync(threadPath), true); + assert.include(NodeFS.readFileSync(threadPath, "utf8"), '{"id":"accepted"}'); + yield* store.close(); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("drops transient provider events before serialization", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); + const canonical = store.logger("canonical"); + const native = store.logger("native"); + const threadId = ThreadId.make("thread-filtered"); + const circularDelta: Record = { type: "content.delta" }; + circularDelta["self"] = circularDelta; + + yield* canonical.write(circularDelta, threadId); + yield* canonical.write({ type: "item.completed", id: "final" }, threadId); + yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); + yield* native.write( + { method: "item/agentMessage/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/transcript/delta", payload: circularDelta }, + threadId, + ); + yield* native.write({ method: "turn/diff/updated", payload: circularDelta }, threadId); + yield* native.write( + { + event: { + direction: "incoming", + stage: "decoded", + payload: { + method: "turn/diff/updated", + get params() { + throw new Error("unused diff snapshots must not be traversed"); + }, + }, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + method: "claude/stream_event/content_block_delta/text_delta", + payload: circularDelta, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + method: "session/update", + payload: { update: { sessionUpdate: "agent_message_chunk" } }, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + direction: "incoming", + stage: "decoded", + payload: { method: "item/commandExecution/outputDelta", params: circularDelta }, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + direction: "incoming", + stage: "decoded", + payload: { + type: "stream_event", + event: { type: "content_block_delta", delta: circularDelta }, + }, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + direction: "incoming", + stage: "raw", + get payload() { + throw new Error("raw frames must not be inspected"); + }, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + type: "message.part.updated", + payload: { properties: { part: { type: "text" } } }, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + type: "message.part.updated", + payload: { + properties: { + part: { type: "tool", state: { status: "running", output: "progress" } }, + }, + }, + }, + }, + threadId, + ); + yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); + yield* store.close(); + + const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") + .trim() + .split("\n") + .map(parseLogLine); + + assert.deepEqual( + lines.map(({ stream, payload }) => ({ stream, payload })), + [ + { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, + { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, + ], + ); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("summarizes large histories without reading their items", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const turns = Array.from({ length: 10_000 }); + Object.defineProperty(turns, 0, { + get: () => { + throw new Error("history must not be serialized"); + }, + }); + + try { + const store = yield* makeEventNdjsonLogStore(basePath); + yield* store.logger("native").write( + { + provider: "codex", + event: { + direction: "incoming", + stage: "decoded", + payload: { id: 42, result: { thread: { id: "native-thread", turns } } }, + }, + }, + ThreadId.make("large-history"), + ); + yield* store.close(); + + const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-history"), "utf8"); + assert.isBelow(Buffer.byteLength(contents), 2_048); + const record = decodeUnknownJson(parseLogLine(contents.trim()).payload); + assert.nestedPropertyVal(record, "event.payload.id", 42); + assert.nestedPropertyVal(record, "event.payload.result.thread.id", "native-thread"); + assert.nestedPropertyVal(record, "event.payload.result.thread.turns.itemCount", 10_000); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("bounds oversized records while retaining failure details", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath); + const logger = store.logger("native"); + const threadId = ThreadId.make("large-error"); + const failure = { + method: "error", + params: { + threadId: "native-thread", + turnId: "native-turn", + error: { message: "The provider is unavailable.", code: "overloaded" }, + output: "x".repeat(128 * 1_024), + }, + }; + yield* logger.write(failure, threadId); + yield* logger.write({ id: "escaped", output: "\u0000".repeat(20_000) }, threadId); + yield* store.close(); + + const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-error"), "utf8"); + const records = contents + .trim() + .split("\n") + .map((line) => decodeUnknownJson(parseLogLine(line).payload)); + assert.isBelow(Buffer.byteLength(contents), 64 * 1_024); + assert.equal(records.length, 2); + assert.nestedPropertyVal(records[0], "params.threadId", "native-thread"); + assert.nestedPropertyVal(records[0], "params.turnId", "native-turn"); + assert.nestedPropertyVal( + records[0], + "params.error.message", + "The provider is unavailable.", + ); + assert.nestedPropertyVal(records[0], "params.error.code", "overloaded"); + assert.propertyVal(records[1], "id", "escaped"); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("bounds canonical diff snapshots before serializing their duplicate payloads", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const threadId = ThreadId.make("large-diff"); + const diff = "diff-payload".repeat(128 * 1_024); + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); + yield* store.logger("canonical").write( + { + type: "turn.diff.updated", + threadId, + turnId: "native-turn", + raw: { method: "turn/diff/updated", payload: { diff } }, + payload: { unifiedDiff: diff }, + }, + threadId, + ); + yield* store.close(); + const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-diff"), "utf8"); + assert.isBelow(Buffer.byteLength(contents), 2_048); + const record = decodeUnknownJson(parseLogLine(contents.trim()).payload); + assert.propertyVal(record, "type", "turn.diff.updated"); + assert.propertyVal(record, "threadId", threadId); + assert.propertyVal(record, "turnId", "native-turn"); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("keeps OpenCode tool input, final output, and errors in native logs", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const threadId = ThreadId.make("thread-tool-lifecycle"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); + const native = store.logger("native"); + const input = { command: "run-build" }; + const states = [ + { status: "pending", input }, + { status: "completed", input, output: "Build complete", time: { start: 1, end: 2 } }, + { status: "error", input, error: "Build failed", time: { start: 3, end: 4 } }, + { status: "unknown", input }, + null, + ]; + const events = states.map((state) => ({ + event: { + type: "message.part.updated", + payload: { properties: { part: { type: "tool", state } } }, + }, + })); + for (const event of events) { + yield* native.write(event, threadId); + } + yield* store.close(); + + const payloads = NodeFS.readFileSync( + ownedLogPath(basePath, "thread-tool-lifecycle"), + "utf8", + ) + .trim() + .split("\n") + .map((line) => parseLogLine(line).payload); + assert.deepEqual( + payloads, + events.map((event) => encodeUnknownJson(event)), + ); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("contains hostile event accessors inside guarded serialization", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + + try { + const logger = yield* makeEventNdjsonLogger(basePath, { + stream: "canonical", + batchWindowMs: 0, + }); + assert.exists(logger); + if (!logger) return; + const hostile = new Proxy( + { id: "hostile" }, + { + get(_target, property) { + if (property === "type") throw new Error("blocked"); + return undefined; + }, + }, + ); + + yield* logger.write(hostile, ThreadId.make("thread-hostile")); + yield* logger.close(); + + const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8"); + assert.notInclude(contents, "blocked"); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("serializes concurrent first writes for the same segment", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); + + try { + const logger = yield* makeEventNdjsonLogger(basePath, { + stream: "canonical", + batchWindowMs: 0, + }); + assert.notEqual(logger, undefined); + if (!logger) { + return; + } + + yield* Effect.all( + [ + logger.write({ id: "evt-concurrent-1" }, null), + logger.write({ id: "evt-concurrent-2" }, null), + ], + { concurrency: "unbounded" }, + ); + yield* logger.close(); + + const globalPath = ownedLogPath(basePath, "_global"); + assert.equal(NodeFS.existsSync(globalPath), true); + const lines = NodeFS.readFileSync(globalPath, "utf8") + .trim() + .split("\n") + .map((line) => parseLogLine(line)); + + assert.equal(lines.length, 2); + assert.deepEqual(lines.map((line) => line.payload).toSorted(), [ + '{"id":"evt-concurrent-1"}', + '{"id":"evt-concurrent-2"}', + ]); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("rotates per-thread files when max size is exceeded", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { + maxBytes: 120, + maxFiles: 2, + batchWindowMs: 0, + }); + const native = store.logger("native"); + const canonical = store.logger("canonical"); + + for (let index = 0; index < 10; index += 1) { + const logger = index % 2 === 0 ? native : canonical; + yield* logger.write( + { + type: "session.started", + threadId: "provider-thread-rotate", + id: `evt-${index}`, + payload: "x".repeat(40), + }, + ThreadId.make("thread-rotate"), + ); + } + yield* store.close(); + + const fileStem = NodePath.basename(ownedLogPath(basePath, "thread-rotate")); + const matchingFiles = NodeFS.readdirSync(tempDir) + .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) + .toSorted(); + + assert.equal( + matchingFiles.some((entry) => entry === `${fileStem}.1`), + true, + ); + assert.equal( + matchingFiles.some((entry) => entry === fileStem || entry === `${fileStem}.2`), + true, + ); + assert.equal( + matchingFiles.some((entry) => entry === `${fileStem}.3`), + false, + ); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("enforces aggregate age and byte retention on startup", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const expiredPath = ownedLogPath(basePath, "expired"); + const oldPath = ownedLogPath(basePath, "old"); + const newPath = ownedLogPath(basePath, "new"); + const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log"); + const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log"); + const ignoredPath = NodePath.join(tempDir, "ignored.txt"); + + try { + yield* TestClock.setTime(1_800_000_000_000); + const now = yield* Clock.currentTimeMillis; + for (const filePath of [expiredPath, oldPath, newPath, unrelatedLogPath, ignoredPath]) { + NodeFS.writeFileSync(filePath, "x".repeat(40)); + } + NodeFS.writeFileSync( + legacyLogPath, + "[2026-01-01T00:00:00.000Z] CANON: legacy provider event\n", + ); + NodeFS.utimesSync(expiredPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); + NodeFS.utimesSync(legacyLogPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); + NodeFS.utimesSync(oldPath, (now - 5_000) / 1_000, (now - 5_000) / 1_000); + NodeFS.utimesSync(newPath, now / 1_000, now / 1_000); + + const store = yield* makeEventNdjsonLogStore(basePath, { + maxAgeMs: 10_000, + maxTotalBytes: 60, + }); + yield* store.close(); + + assert.equal(NodeFS.existsSync(expiredPath), false); + assert.equal(NodeFS.existsSync(legacyLogPath), false); + assert.equal(NodeFS.existsSync(oldPath), false); + assert.equal(NodeFS.existsSync(newPath), true); + assert.equal(NodeFS.existsSync(unrelatedLogPath), true); + assert.equal(NodeFS.existsSync(ignoredPath), true); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("does not prune an active thread sink during an unrelated flush", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const activePath = ownedLogPath(basePath, "active"); + + try { + yield* TestClock.setTime(1_800_000_000_000); + const store = yield* makeEventNdjsonLogStore(basePath, { + batchWindowMs: 0, + maxAgeMs: 1, + retentionCheckIntervalMs: 1, + }); + const logger = store.logger("native"); + + yield* logger.write({ id: "active-before-retention" }, ThreadId.make("active")); + assert.equal(NodeFS.existsSync(activePath), true); + + yield* TestClock.adjust("2 millis"); + yield* logger.write({ id: "retention-trigger" }, ThreadId.make("other")); + + assert.equal(NodeFS.existsSync(activePath), true); + yield* store.close(); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it("attributes batches that were written before a later chunk fails", () => { + const records: ReadonlyArray = [ + { + stream: "native", + threadSegment: "thread", + line: "first", + bytes: 5, + }, + { + stream: "canonical", + threadSegment: "thread", + line: "second", + bytes: 6, + }, + ]; + const attributed: Array = []; + let writes = 0; + + assert.throws(() => + writeBatchedMessages( + { + write: () => { + writes += 1; + if (writes === 2) throw new Error("simulated disk exhaustion"); + }, + }, + records, + 5, + (written) => attributed.push(...written), + ), + ); + assert.deepEqual(attributed, [records[0]]); + }); + + it.effect("reports logical provider log writes to resource attribution", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); + + try { + const attribution = yield* ResourceAttribution.make(); + const logger = yield* makeEventNdjsonLogger(basePath, { + stream: "native", + batchWindowMs: 0, + attribution, + }); + assert.notEqual(logger, undefined); + if (!logger) { + return; + } + + yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution")); + yield* logger.close(); + + const snapshot = yield* attribution.snapshot; + assert.equal(snapshot.entries.length, 1); + assert.equal(snapshot.entries[0]?.component, "provider-event-log"); + assert.equal(snapshot.entries[0]?.operation, "native.append"); + assert.equal(snapshot.entries[0]?.count, 1); + assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); +}); diff --git a/apps/server/src/provider/EventNdjsonLogger.ts b/apps/server/src/provider/EventNdjsonLogger.ts new file mode 100644 index 0000000000000000000000000000000000000000..491119c3cdfdb82156040334064c44e8be6c5ec1 --- /dev/null +++ b/apps/server/src/provider/EventNdjsonLogger.ts @@ -0,0 +1,805 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Best-effort provider event logging with one shared writer per thread. + * + * Native and canonical views share batching, rotation, and retention state so + * they cannot race while appending to the same thread-scoped file. + */ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import type { ThreadId } from "@t3tools/contracts"; +import { RotatingFileSink } from "@t3tools/shared/logging"; +import { errorTag } from "@t3tools/shared/observability"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import { toSafeThreadAttachmentSegment } from "../attachmentStore.ts"; +import type { ResourceAttribution } from "../resourceTelemetry/ResourceAttribution.ts"; + +const MEBIBYTE = 1024 * 1024; +const DAY_MS = 24 * 60 * 60 * 1_000; +const DEFAULT_MAX_BYTES = 10 * MEBIBYTE; +const DEFAULT_MAX_FILES = 10; +const DEFAULT_BATCH_WINDOW_MS = 1_000; +const DEFAULT_MAX_TOTAL_BYTES = 512 * MEBIBYTE; +const DEFAULT_MAX_AGE_MS = 14 * DAY_MS; +const DEFAULT_RETENTION_CHECK_INTERVAL_MS = 5 * 60 * 1_000; +const DEFAULT_MAX_BUFFERED_BYTES = MEBIBYTE; +const DEFAULT_MAX_BUFFERED_RECORDS = 512; +const MAX_RECORD_CHARACTERS = 64 * 1024; +const MAX_RECORD_FIELDS = 1_024; +const MAX_RECORD_DEPTH = 16; +const GLOBAL_THREAD_SEGMENT = "_global"; +const LOG_SCOPE = "provider-observability"; +const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + +const transientCanonicalEventTypes = new Set([ + "content.delta", + "hook.progress", + "item.updated", + "task.progress", + "thread.realtime.audio.delta", + "tool.progress", + "turn.proposed.delta", +]); +const transientNativeMethods = new Set([ + "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/plan/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "thread/realtime/outputAudio/delta", + "thread/realtime/transcript/delta", + "turn/diff/updated", +]); +const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); + +export type EventNdjsonStream = "native" | "canonical" | "orchestration"; + +export interface EventNdjsonLogger { + readonly filePath: string; + readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; + readonly close: () => Effect.Effect; +} + +export interface EventNdjsonLogStore { + readonly filePath: string; + readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger; + readonly close: () => Effect.Effect; +} + +export interface EventNdjsonLogStoreOptions { + readonly maxBytes?: number; + readonly maxFiles?: number; + readonly batchWindowMs?: number; + readonly maxTotalBytes?: number; + readonly maxAgeMs?: number; + readonly retentionCheckIntervalMs?: number; + readonly maxBufferedBytes?: number; + readonly maxBufferedRecords?: number; + readonly attribution?: ResourceAttribution["Service"]; +} + +export interface EventNdjsonLoggerOptions extends EventNdjsonLogStoreOptions { + readonly stream: EventNdjsonStream; +} + +export class EventNdjsonLogConfigurationError extends Schema.TaggedError()( + "EventNdjsonLogConfigurationError", + { + filePath: Schema.String, + option: Schema.String, + value: Schema.Number, + minimum: Schema.Number, + }, +) { + override get message(): string { + return `Provider event log option '${this.option}' must be an integer >= ${this.minimum}; received ${this.value} for '${this.filePath}'`; + } +} + +export class EventNdjsonLogDirectoryError extends Schema.TaggedError()( + "EventNdjsonLogDirectoryError", + { + directory: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to create provider event log directory '${this.directory}'`; + } +} + +export type EventNdjsonLogStoreError = + | EventNdjsonLogConfigurationError + | EventNdjsonLogDirectoryError; + +interface ResolvedOptions { + readonly maxBytes: number; + readonly maxFiles: number; + readonly batchWindowMs: number; + readonly maxTotalBytes: number; + readonly maxAgeMs: number; + readonly retentionCheckIntervalMs: number; + readonly maxBufferedBytes: number; + readonly maxBufferedRecords: number; + readonly attribution: ResourceAttribution["Service"] | undefined; +} + +export interface PendingRecord { + readonly stream: EventNdjsonStream; + readonly threadSegment: string; + readonly line: string; + readonly bytes: number; +} + +interface StoreState { + readonly pending: Array; + readonly pendingBytes: number; + readonly sinks: ReadonlyMap; + readonly flushScheduled: boolean; + readonly closed: boolean; + readonly lastRetentionAt: number; +} + +interface AttributionSummary { + readonly stream: EventNdjsonStream; + readonly count: number; + readonly logicalWriteBytes: number; +} + +interface FileOperationFailure { + readonly filePath: string; + readonly cause: unknown; +} + +interface RetentionResult { + readonly failures: ReadonlyArray; +} + +interface DrainResult { + readonly attributions: ReadonlyArray; + readonly failures: ReadonlyArray; +} + +function logWarning(message: string, context: Record): Effect.Effect { + return Effect.logWarning(message, context).pipe(Effect.annotateLogs({ scope: LOG_SCOPE })); +} + +function resolveThreadSegment(raw: string | null | undefined): string { + const normalized = typeof raw === "string" ? toSafeThreadAttachmentSegment(raw) : null; + return normalized ?? GLOBAL_THREAD_SEGMENT; +} + +function resolveStreamLabel(stream: EventNdjsonStream): string { + return stream === "native" ? "NTIVE" : stream === "orchestration" ? "ORCH" : "CANON"; +} + +function providerLogPrefix(filePath: string): string { + const basename = NodePath.basename(filePath); + const extension = NodePath.extname(basename); + return `${extension.length > 0 ? basename.slice(0, -extension.length) : basename}.`; +} + +function providerLogPath(directory: string, prefix: string, threadSegment: string): string { + return NodePath.join(directory, `${prefix}${threadSegment}.log`); +} + +export function shouldPersistProviderEvent(stream: EventNdjsonStream, event: unknown): boolean { + if (stream === "orchestration" || typeof event !== "object" || event === null) { + return true; + } + try { + const type = Reflect.get(event, "type"); + if (typeof type === "string" && transientCanonicalEventTypes.has(type)) { + return false; + } + if (stream !== "native") return true; + + const nested = Reflect.get(event, "event"); + const envelope = typeof nested === "object" && nested !== null ? nested : event; + // Decoded frames carry the same information as raw frames without another + // copy of every token delta. Decode failures have their own diagnostic frame. + if (Reflect.get(envelope, "stage") === "raw") return false; + const decodedPayload = Reflect.get(envelope, "payload"); + const nativeEvent = + Reflect.get(envelope, "stage") === "decoded" && + typeof decodedPayload === "object" && + decodedPayload !== null + ? decodedPayload + : envelope; + const method = Reflect.get(nativeEvent, "method"); + if ( + typeof method === "string" && + (transientNativeMethods.has(method) || + method.startsWith("claude/stream_event/content_block_delta/")) + ) { + return false; + } + + const nativeType = Reflect.get(nativeEvent, "type"); + if (nativeType === "message.part.delta") return false; + if (nativeType === "stream_event") { + const streamEvent = Reflect.get(nativeEvent, "event"); + if ( + typeof streamEvent === "object" && + streamEvent !== null && + Reflect.get(streamEvent, "type") === "content_block_delta" + ) { + return false; + } + } + + const payload = Reflect.get(nativeEvent, "payload"); + if (typeof payload !== "object" || payload === null) return true; + + if (method === "session/update") { + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return true; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType !== "string" || !transientAcpUpdates.has(updateType); + } + + if (nativeType === "message.part.updated") { + const properties = Reflect.get(payload, "properties"); + if (typeof properties !== "object" || properties === null) return true; + const part = Reflect.get(properties, "part"); + if (typeof part !== "object" || part === null) return true; + const partType = Reflect.get(part, "type"); + if (partType === "text" || partType === "reasoning") return false; + if (partType === "tool") { + // Running snapshots repeat growing output. Pending and terminal states stay in the log. + const state = Reflect.get(part, "state"); + if (typeof state === "object" && state !== null) { + return Reflect.get(state, "status") !== "running"; + } + } + return true; + } + + return true; + } catch { + return true; + } +} + +const summaryFields = [ + "provider", + "protocol", + "kind", + "providerSessionId", + "direction", + "stage", + "type", + "subtype", + "method", + "id", + "threadId", + "turnId", + "requestId", + "session_id", + "status", + "is_error", + "api_error_status", + "terminal_reason", + "stop_reason", + "operation", + "code", + "willRetry", + "message", + "event", + "payload", + "params", + "result", + "thread", + "turn", + "error", + "turns", + "items", + "content", +] as const; + +function summarizeProviderEvent(event: unknown): unknown { + let remainingFields = 128; + let remainingCharacters = 8 * 1024; + const summarize = (value: unknown, depth: number): unknown => { + if (typeof value === "string") { + if (value.length > Math.min(1_024, remainingCharacters)) { + return { omittedCharacters: value.length }; + } + remainingCharacters -= value.length; + return value; + } + if (value === null || typeof value === "number" || typeof value === "boolean") return value; + if (typeof value !== "object") return undefined; + if (Array.isArray(value)) return { itemCount: value.length }; + const summary: Record = { truncated: true }; + if (depth >= 6) return summary; + for (const key of summaryFields) { + if (remainingFields <= 0) break; + const nested = Reflect.get(value, key); + if (nested === undefined) continue; + remainingFields -= 1; + summary[key] = summarize(nested, depth + 1); + } + return summary; + }; + try { + return summarize(event, 0); + } catch { + return { truncated: true }; + } +} + +/** Bounds traversal before adapters copy payloads or the logger encodes them. */ +export function boundProviderEventForLogging(event: unknown): unknown { + let remainingCharacters = MAX_RECORD_CHARACTERS; + let remainingFields = MAX_RECORD_FIELDS; + const ancestors = new WeakSet(); + const fits = (value: unknown, depth: number): boolean => { + if (typeof value === "string") { + remainingCharacters -= value.length; + return remainingCharacters >= 0; + } + if (typeof value !== "object" || value === null) return true; + if (depth > MAX_RECORD_DEPTH || ancestors.has(value)) return false; + if (Array.isArray(value) && value.length > remainingFields) return false; + ancestors.add(value); + for (const key in value) { + if (!Object.hasOwn(value, key)) continue; + remainingFields -= 1; + remainingCharacters -= key.length; + if ( + remainingFields < 0 || + remainingCharacters < 0 || + !fits(Reflect.get(value, key), depth + 1) + ) + return false; + } + ancestors.delete(value); + return true; + }; + try { + if (fits(event, 0)) return event; + } catch { + // A failing accessor must not escape into provider processing. + } + return summarizeProviderEvent(event); +} + +export function writeBatchedMessages( + sink: Pick, + records: ReadonlyArray, + maxBytes: number, + onWritten: (records: ReadonlyArray) => void, +): void { + let pendingRecords: Array = []; + let pendingBytes = 0; + + const flush = () => { + if (pendingRecords.length === 0) return; + const writtenRecords = pendingRecords; + sink.write(writtenRecords.map((record) => record.line).join("")); + onWritten(writtenRecords); + pendingRecords = []; + pendingBytes = 0; + }; + + for (const record of records) { + if (pendingBytes > 0 && pendingBytes + record.bytes > maxBytes) { + flush(); + } + pendingRecords.push(record); + pendingBytes += record.bytes; + if (pendingBytes >= maxBytes) { + flush(); + } + } + flush(); +} + +function isProviderLogFile(filePath: string, fileName: string, filePrefix: string): boolean { + if (!/\.log(?:\.\d+)?$/u.test(fileName)) return false; + if (fileName.startsWith(filePrefix)) return true; + + const descriptor = NodeFS.openSync(filePath, "r"); + try { + const header = Buffer.alloc(256); + const bytesRead = NodeFS.readSync(descriptor, header, 0, header.byteLength, 0); + return /^\[[^\]\r\n]+\] (?:NTIVE|CANON|ORCH): /u.test(header.toString("utf8", 0, bytesRead)); + } finally { + NodeFS.closeSync(descriptor); + } +} + +function enforceRetention(input: { + readonly directory: string; + readonly maxTotalBytes: number; + readonly maxAgeMs: number; + readonly activeFilePaths: ReadonlySet; + readonly filePrefix: string; + readonly now: number; +}): RetentionResult { + const failures: Array = []; + const files: Array<{ filePath: string; mtimeMs: number; size: number }> = []; + + let entries: ReadonlyArray; + try { + entries = NodeFS.readdirSync(input.directory, { withFileTypes: true }); + } catch (cause) { + return { failures: [{ filePath: input.directory, cause }] }; + } + + for (const entry of entries) { + if (!entry.isFile()) continue; + const filePath = NodePath.join(input.directory, entry.name); + try { + if (!isProviderLogFile(filePath, entry.name, input.filePrefix)) continue; + const stat = NodeFS.statSync(filePath); + files.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size }); + } catch (cause) { + failures.push({ filePath, cause }); + } + } + + let totalBytes = files.reduce((total, file) => total + file.size, 0); + const remove = (file: (typeof files)[number]) => { + if (input.activeFilePaths.has(file.filePath)) return false; + try { + NodeFS.rmSync(file.filePath, { force: true }); + totalBytes -= file.size; + return true; + } catch (cause) { + failures.push({ filePath: file.filePath, cause }); + return false; + } + }; + + const retained = files.filter((file) => { + if (input.now - file.mtimeMs <= input.maxAgeMs) return true; + return !remove(file); + }); + + for (const file of retained.toSorted( + (left, right) => left.mtimeMs - right.mtimeMs || left.filePath.localeCompare(right.filePath), + )) { + if (totalBytes <= input.maxTotalBytes) break; + remove(file); + } + + return { failures }; +} + +function validateOption(input: { + readonly filePath: string; + readonly option: string; + readonly value: number; + readonly minimum: number; +}): EventNdjsonLogConfigurationError | undefined { + if (Number.isInteger(input.value) && input.value >= input.minimum) return undefined; + return new EventNdjsonLogConfigurationError(input); +} + +function resolveOptions( + filePath: string, + options: EventNdjsonLogStoreOptions, +): Effect.Effect { + const resolved = { + maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES, + maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES, + batchWindowMs: options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS, + maxTotalBytes: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES, + maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS, + retentionCheckIntervalMs: + options.retentionCheckIntervalMs ?? DEFAULT_RETENTION_CHECK_INTERVAL_MS, + maxBufferedBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES, + maxBufferedRecords: options.maxBufferedRecords ?? DEFAULT_MAX_BUFFERED_RECORDS, + attribution: options.attribution, + } satisfies ResolvedOptions; + + const validations = [ + ["maxBytes", resolved.maxBytes, 1], + ["maxFiles", resolved.maxFiles, 1], + ["batchWindowMs", resolved.batchWindowMs, 0], + ["maxTotalBytes", resolved.maxTotalBytes, 1], + ["maxAgeMs", resolved.maxAgeMs, 1], + ["retentionCheckIntervalMs", resolved.retentionCheckIntervalMs, 1], + ["maxBufferedBytes", resolved.maxBufferedBytes, 1], + ["maxBufferedRecords", resolved.maxBufferedRecords, 1], + ] as const; + + for (const [option, value, minimum] of validations) { + const error = validateOption({ filePath, option, value, minimum }); + if (error) return Effect.fail(error); + } + return Effect.succeed(resolved); +} + +function drainPending(input: { + readonly directory: string; + readonly options: ResolvedOptions; + readonly state: StoreState; + readonly filePrefix: string; + readonly now: number; + readonly timerFired: boolean; + readonly close: boolean; +}): readonly [DrainResult, StoreState] { + if (input.state.closed) { + return [{ attributions: [], failures: [] }, input.state]; + } + + const sinks = new Map(input.state.sinks); + const failures: Array = []; + const attributionByStream = new Map< + EventNdjsonStream, + { count: number; logicalWriteBytes: number } + >(); + const recordsBySegment = new Map>(); + + for (const record of input.state.pending) { + const records = recordsBySegment.get(record.threadSegment) ?? []; + records.push(record); + recordsBySegment.set(record.threadSegment, records); + } + + for (const [threadSegment, records] of recordsBySegment) { + const filePath = providerLogPath(input.directory, input.filePrefix, threadSegment); + let sink = sinks.get(threadSegment); + if (!sink) { + try { + sink = new RotatingFileSink({ + filePath, + maxBytes: input.options.maxBytes, + maxFiles: input.options.maxFiles, + throwOnError: true, + }); + sinks.set(threadSegment, sink); + } catch (cause) { + failures.push({ filePath, cause }); + continue; + } + } + + try { + writeBatchedMessages(sink, records, input.options.maxBytes, (writtenRecords) => { + for (const record of writtenRecords) { + const current = attributionByStream.get(record.stream) ?? { + count: 0, + logicalWriteBytes: 0, + }; + attributionByStream.set(record.stream, { + count: current.count + 1, + logicalWriteBytes: current.logicalWriteBytes + record.bytes, + }); + } + }); + } catch (cause) { + sinks.delete(threadSegment); + failures.push({ filePath, cause }); + } + } + + const retentionDue = + input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs; + const retention = retentionDue + ? enforceRetention({ + directory: input.directory, + maxTotalBytes: input.options.maxTotalBytes, + maxAgeMs: input.options.maxAgeMs, + activeFilePaths: new Set( + Array.from(sinks.keys(), (threadSegment) => + providerLogPath(input.directory, input.filePrefix, threadSegment), + ), + ), + filePrefix: input.filePrefix, + now: input.now, + }) + : { failures: [] }; + + return [ + { + attributions: Array.from(attributionByStream, ([stream, value]) => ({ + stream, + ...value, + })), + failures: [...failures, ...retention.failures], + }, + { + pending: [], + pendingBytes: 0, + sinks, + flushScheduled: input.timerFired ? false : input.state.flushScheduled, + closed: input.close, + lastRetentionAt: retentionDue ? input.now : input.state.lastRetentionAt, + }, + ]; +} + +const serializeEvent = Effect.fnUntraced(function* (event: unknown) { + return yield* encodeUnknownJsonString(event).pipe( + Effect.catch((error) => + logWarning("failed to serialize provider event log record", { + errorTag: errorTag(error), + }).pipe(Effect.as(undefined)), + ), + ); +}); + +export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( + filePath: string, + options: EventNdjsonLogStoreOptions = {}, +): Effect.fn.Return { + const resolved = yield* resolveOptions(filePath, options); + const directory = NodePath.dirname(filePath); + const filePrefix = providerLogPrefix(filePath); + + yield* Effect.try({ + try: () => NodeFS.mkdirSync(directory, { recursive: true }), + catch: (cause) => new EventNdjsonLogDirectoryError({ directory, cause }), + }); + + const initializedAt = yield* Clock.currentTimeMillis; + const initialRetention = yield* Effect.sync(() => + enforceRetention({ + directory, + maxTotalBytes: resolved.maxTotalBytes, + maxAgeMs: resolved.maxAgeMs, + activeFilePaths: new Set(), + filePrefix, + now: initializedAt, + }), + ); + for (const failure of initialRetention.failures) { + yield* logWarning("provider event log retention failed", { + filePath: failure.filePath, + errorTag: errorTag(failure.cause), + }); + } + + const stateRef = yield* SynchronizedRef.make({ + pending: [], + pendingBytes: 0, + sinks: new Map(), + flushScheduled: false, + closed: false, + lastRetentionAt: initializedAt, + }); + const timerScope = yield* Scope.make(); + + const flush = Effect.fnUntraced(function* (timerFired: boolean, close: boolean) { + const startedAt = yield* Clock.currentTimeMillis; + const result = yield* SynchronizedRef.modifyEffect(stateRef, (state) => + Effect.sync(() => + drainPending({ + directory, + options: resolved, + state, + filePrefix, + now: startedAt, + timerFired, + close, + }), + ), + ); + + for (const failure of result.failures) { + yield* logWarning("provider event log write or retention failed", { + filePath: failure.filePath, + errorTag: errorTag(failure.cause), + }); + } + + if (resolved.attribution && result.attributions.length > 0) { + const completedAt = yield* Clock.currentTimeMillis; + const durationMs = Math.max(0, completedAt - startedAt); + const totalBytes = result.attributions.reduce( + (total, entry) => total + entry.logicalWriteBytes, + 0, + ); + yield* Effect.forEach( + result.attributions, + (entry) => + resolved.attribution?.record({ + component: "provider-event-log", + operation: `${entry.stream}.append`, + logicalWriteBytes: entry.logicalWriteBytes, + count: entry.count, + durationMs: + totalBytes === 0 + ? 0 + : Math.round(durationMs * (entry.logicalWriteBytes / totalBytes)), + }) ?? Effect.void, + { discard: true }, + ); + } + }); + + const scheduleFlush = Effect.fnUntraced(function* () { + yield* Effect.forkIn( + Effect.sleep(resolved.batchWindowMs).pipe(Effect.andThen(flush(true, false))), + timerScope, + { startImmediately: true }, + ); + }); + + const close = Effect.fnUntraced(function* () { + yield* flush(false, true); + yield* Scope.close(timerScope, Exit.void); + }); + + const loggerViews = new Map(); + const logger = (stream: EventNdjsonStream): EventNdjsonLogger => { + const existing = loggerViews.get(stream); + if (existing) return existing; + + const write = Effect.fnUntraced(function* (event: unknown, threadId: ThreadId | null) { + if (!shouldPersistProviderEvent(stream, event)) return; + let payload = yield* serializeEvent(boundProviderEventForLogging(event)); + if (payload === undefined) return; + // Escaping can expand strings beyond their input size. Keep that bounded + // serialization out of the file too, while retaining routing/error fields. + if (Buffer.byteLength(payload) > MAX_RECORD_CHARACTERS) { + payload = yield* serializeEvent(summarizeProviderEvent(event)); + if (payload === undefined) return; + } + + const observedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const line = `[${observedAt}] ${resolveStreamLabel(stream)}: ${payload}\n`; + const bytes = Buffer.byteLength(line); + const action = yield* SynchronizedRef.modifyEffect(stateRef, (state) => { + if (state.closed) { + return Effect.succeed([{ flush: false }, state] as const); + } + const pending = state.pending; + pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes }); + const pendingBytes = state.pendingBytes + bytes; + const flush = + resolved.batchWindowMs === 0 || + pending.length >= resolved.maxBufferedRecords || + pendingBytes >= resolved.maxBufferedBytes; + const schedule = !flush && !state.flushScheduled; + const nextState = { + ...state, + pending, + pendingBytes, + flushScheduled: state.flushScheduled || schedule, + }; + return (schedule ? scheduleFlush() : Effect.void).pipe( + Effect.as([{ flush }, nextState] as const), + ); + }).pipe(Effect.uninterruptible); + + if (action.flush) { + yield* flush(false, false); + } + }); + + const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger; + loggerViews.set(stream, view); + return view; + }; + + return { filePath, logger, close } satisfies EventNdjsonLogStore; +}); + +export const makeEventNdjsonLogger = Effect.fnUntraced(function* ( + filePath: string, + options: EventNdjsonLoggerOptions, +): Effect.fn.Return { + const store = yield* makeEventNdjsonLogStore(filePath, options).pipe( + Effect.catch((error) => + logWarning(error.message, { error }).pipe( + Effect.as(undefined), + ), + ), + ); + if (!store) return undefined; + return { ...store.logger(options.stream), close: store.close }; +}); diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a01812c6833dd79f8864d61c9e8eca5da722fc0 --- /dev/null +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -0,0 +1,845 @@ +// @effect-diagnostics nodeBuiltinImport:off - resolves the mock ACP agent script path relative to this test file. +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { GrokSettings } from "@t3tools/contracts"; + +import { + buildGrokModelCapabilities, + buildGrokModelsFromSessionModelState, + buildInitialGrokProviderSnapshot, + checkGrokProviderStatus, + grokSlashCommandsFromInitialize, + parseGrokModelsCliOutput, +} from "./GrokProvider.ts"; +import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; +import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; + +const decodeGrokSettings = Schema.decodeSync(GrokSettings); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); + +const LOGGED_IN_MODELS_OUTPUT = [ + "You are logged in with grok.com.", + "", + "Default model: grok-4.6", + "", + "Available models:", + " * grok-4.6 (default)", + " - grok-4.5", + "", +].join("\n"); + +const LOGGED_OUT_MODELS_OUTPUT = LOGGED_IN_MODELS_OUTPUT.replace( + "You are logged in with grok.com.", + "You are not authenticated.", +); + +describe("parseGrokModelsCliOutput", () => { + it("reads login state and model slugs, marking the default", () => { + const parsed = parseGrokModelsCliOutput(LOGGED_IN_MODELS_OUTPUT); + expect(parsed.authenticated).toBe(true); + expect(parsed.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["grok-4.6", true], + ["grok-4.5", false], + ]); + }); + + it("detects a logged-out CLI even though it exits 0", () => { + expect(parseGrokModelsCliOutput(LOGGED_OUT_MODELS_OUTPUT).authenticated).toBe(false); + }); + + it("returns unknown auth for unrecognized output", () => { + expect(parseGrokModelsCliOutput("grok 9.9.9\n").authenticated).toBeNull(); + }); +}); + +describe("grokSlashCommandsFromInitialize", () => { + it("publishes native ACP commands and input hints without permission overrides", () => { + const commands = grokSlashCommandsFromInitialize({ + protocolVersion: 1, + _meta: { + availableCommands: [ + { name: "compact", description: "Compress history", input: { hint: "what to preserve" } }, + { + name: "always-approve", + description: "Skip permission prompts", + input: { hint: "on|off" }, + }, + { name: "context", description: "Show context usage", input: null }, + { name: "session-info", description: "Show session details" }, + { name: "deep-research", description: "Research a topic", input: { hint: "" } }, + { name: "workflow", description: "Manage workflows", input: { hint: "runs" } }, + { name: "goal", description: "Manage an autonomous goal", input: { hint: "status" } }, + ], + }, + }); + expect(commands.map((command) => command.name)).toEqual([ + "compact", + "session-info", + "deep-research", + "workflow", + "goal", + ]); + expect(commands[0]?.input).toEqual({ hint: "what to preserve" }); + expect(commands[2]).toEqual({ + name: "deep-research", + description: "Research a topic", + input: { hint: "" }, + }); + }); + + it("keeps valid commands when other metadata entries are malformed", () => { + const commands = grokSlashCommandsFromInitialize({ + protocolVersion: 1, + _meta: { + availableCommands: [ + null, + { name: "broken", description: 42 }, + { name: " ", description: "Empty name" }, + { name: " session-info ", description: " Session details " }, + { name: "session-info", description: "Updated session details" }, + ], + }, + }); + expect(commands.map((command) => command.name)).toEqual(["compact", "session-info"]); + expect(commands[1]?.description).toBe("Updated session details"); + }); + + it("keeps compact available for older agents without command metadata", () => { + for (const _meta of [undefined, {}, { availableCommands: "invalid" }]) { + expect( + grokSlashCommandsFromInitialize({ protocolVersion: 1, ...(_meta ? { _meta } : {}) }).map( + (command) => command.name, + ), + ).toEqual(["compact"]); + } + }); +}); + +describe("buildGrokModelsFromSessionModelState", () => { + it("marks the agent's current model as default and keeps reasoning options", () => { + const models = buildGrokModelsFromSessionModelState({ + currentModelId: "grok-4.6", + availableModels: [ + { + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "high", + reasoningEfforts: [{ value: "high", label: "High", default: true }], + }, + }, + { modelId: "grok-4.5", name: "Grok 4.5" }, + ], + }); + expect(models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["grok-4.6", true], + ["grok-4.5", false], + ]); + expect(models[0]?.capabilities?.optionDescriptors).toHaveLength(1); + }); +}); + +describe("buildGrokModelCapabilities", () => { + it("preserves ACP-provided reasoning labels and the active default", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "xhigh", + reasoningEfforts: [ + { value: "xhigh", label: "Extra High Effort", default: true }, + { value: "high", label: "High Effort", default: true }, + { value: "medium", label: "Medium Effort" }, + { value: "low", label: "Low Effort" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "xhigh", + options: [ + { id: "xhigh", label: "Extra High Effort", isDefault: true }, + { id: "high", label: "High Effort" }, + { id: "medium", label: "Medium Effort" }, + { id: "low", label: "Low Effort" }, + ], + }, + ]); + }); + + it("uses raw ACP values when option labels are omitted", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "xhigh", + reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "xhigh", + options: [ + { id: "xhigh", label: "xhigh" }, + { id: "medium", label: "medium" }, + ], + }, + ]); + }); + + it("keeps ACP current effort separate from its collapsed advertised default", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "medium", + reasoningEfforts: [ + { value: "xhigh", label: "Extra High Effort", default: true }, + { value: "high", label: "High Effort", default: true }, + { value: "medium", label: "Medium Effort" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "medium", + options: [ + { id: "xhigh", label: "Extra High Effort", isDefault: true }, + { id: "high", label: "High Effort" }, + { id: "medium", label: "Medium Effort" }, + ], + }, + ]); + }); + + it("preserves ACP descriptions and falls back from invalid values to valid ids", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "high", + reasoningEfforts: [ + { + id: "high", + value: "not a token", + label: "High Effort", + description: "Higher implementation quality", + default: true, + }, + { id: "bad id", value: "also invalid", label: "Invalid" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "high", + options: [ + { + id: "high", + label: "High Effort", + description: "Higher implementation quality", + isDefault: true, + }, + ], + }, + ]); + }); + + it("accepts an advertised ACP menu when the support flag is omitted", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + reasoningEffort: "high", + reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], + }, + }); + + expect(capabilities.optionDescriptors).toHaveLength(1); + }); + + it("honors an explicit ACP opt-out even when a menu is present", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: false, + reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([]); + }); + + it("does not synthesize a reasoning menu when ACP omits it", () => { + expect( + buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" }, + }).optionDescriptors, + ).toEqual([]); + }); + + it("keeps non-reasoning Grok models free of reasoning controls", () => { + expect( + buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, + ).toEqual([]); + }); +}); + +describe("buildInitialGrokProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialGrokProviderSnapshot( + decodeGrokSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("returns a disabled snapshot by default — Grok is opt-in", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({})); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + }), + ); + + it.effect("returns a pending snapshot when enabled", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialGrokProviderSnapshot( + decodeGrokSettings({ enabled: true }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking Grok"); + expect(snapshot.requiresNewThreadForModelChange).toBeUndefined(); + expect(snapshot.supportsConversationRollback).toBe(false); + }), + ); +}); + +it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { + it.effect("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkGrokProviderStatus( + decodeGrokSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/grok-binary", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + + it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => + Effect.gen(function* () { + const secretStderr = "broken grok install: secret-token-value"; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); + const grokPath = writeFakeCli({ + directory: dir, + name: "grok", + source: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`, + "process.exit(2);", + "", + ].join("\n"), + }); + + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe("Grok CLI is installed but failed to run."); + expect(snapshot.message).not.toContain(secretStderr); + }), + ); + + // A stand-in for the Grok CLI: `--version` and `models` print canned text, + // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. + const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); + const mockAgentPath = NodePath.resolve(__dirname, "../../scripts/acp-mock-agent.ts"); + return writeFakeCli({ + directory: dir, + name: "grok", + source: [ + 'if (process.argv[2] === "--version") {', + ' process.stdout.write("grok 1.0.13\\n");', + " process.exit(0);", + "}", + 'if (process.argv[2] === "models") {', + // @effect-diagnostics-next-line preferSchemaOverJson:off + ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`, + " process.exit(0);", + "}", + 'if (process.argv[2] !== "agent") process.exit(1);', + ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]), + "", + ].join("\n"), + }); + }); + + it.effect("reports ready with ACP-discovered models when logged in", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_IN_MODELS_OUTPUT, + acp: true, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("1.0.13"); + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "cached_token", + label: "Grok account", + }); + // The mock agent advertises grok-4.6 with reasoning options in initialize._meta. + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); + expect(snapshot.models[0]?.isDefault).toBe(true); + expect( + snapshot.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id) ?? [], + ).toEqual(["reasoningEffort"]); + }), + ); + + it.effect("reports unauthenticated from `grok models` without starting a session", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_OUT_MODELS_OUTPUT, + acp: true, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, + ); + }), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toContain("grok login"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); + }), + ); + + it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_IN_MODELS_OUTPUT, + acp: false, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, + ); + }), + ); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.installed).toBe(true); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["grok-4.6", true], + ["grok-4.5", false], + ]); + expect(snapshot.message).toContain("ACP initialize failed"); + expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["compact"]); + }), + ); + + it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_OUT_MODELS_OUTPUT, + acp: false, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "xai-test-key" }, + ); + }), + ); + + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "api_key", + label: "xAI API key", + }); + expect(snapshot.status).toBe("warning"); + }), + ); +}); + +describe("Grok usage limits", () => { + const checkedAt = "2026-09-16T00:00:00.000Z"; + + it("uses the reported subscription percentage and weekly reset", () => { + const limits = grokUsageResponseToLimits( + { + config: { + creditUsagePercent: 100, + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + end: "2026-09-18T03:10:30.159171+00:00", + }, + }, + }, + checkedAt, + ); + expect(limits.windows).toEqual([ + { + id: "subscription", + kind: "weekly", + label: "Weekly", + usedPercent: 100, + resetsAt: "2026-09-18T03:10:30.159Z", + }, + ]); + }); + + it("does not invent allowance or reset dates when billing omits them", () => { + for (const response of [{}, { config: {} }, { config: { creditUsagePercent: NaN } }]) { + const limits = grokUsageResponseToLimits(response, checkedAt); + expect(limits.windows).toEqual([]); + // Nothing metered yet, which xAI reports by omitting the field until + // usage registers. Marking it `unsupported` would drop the account from + // the Limits view for good; leaving the marker off keeps it listed. + expect(limits.unavailable).toBeUndefined(); + } + expect( + grokUsageResponseToLimits({ config: { creditUsagePercent: 0 } }, checkedAt).windows, + ).toEqual([{ id: "subscription", kind: "other", label: "Subscription", usedPercent: 0 }]); + expect( + grokUsageResponseToLimits( + { + config: { + creditUsagePercent: 120, + currentPeriod: { type: "USAGE_PERIOD_TYPE_MONTHLY", end: "invalid" }, + }, + }, + checkedAt, + ).windows, + ).toEqual([{ id: "subscription", kind: "monthly", label: "Monthly", usedPercent: 100 }]); + }); +}); + +it.layer(NodeServices.layer)("readGrokAccount", (it) => { + it.effect("reads the configured Grok home and prefers the current login scope to legacy", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString( + NodePath.join(directory, "auth.json"), + '{"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828":{"key":"session-token","auth_mode":"oauth"},"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', + ); + const { usageLimits: limits } = yield* readGrokAccount({ + GROK_HOME: directory, + HOME: "/unrelated-home", + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.method).toBe("GET"); + expect(request.url).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits"); + expect(request.headers.authorization).toBe("Bearer session-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ config: { creditUsagePercent: 37 } }), + ), + ); + }), + ), + ); + expect(limits.windows[0]?.usedPercent).toBe(37); + }).pipe(Effect.scoped), + ); + + it.effect( + "uses GROK_AUTH without reading stored credentials and accepts the legacy login scope", + () => + readGrokAccount({ + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: (path) => + path.endsWith("auth.json") + ? Effect.die("must not read stored credentials") + : Effect.succeed(""), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.headers.authorization).toBe("Bearer legacy-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ config: { creditUsagePercent: 12 } }), + ), + ); + }), + ), + Effect.tap(({ usageLimits }) => + Effect.sync(() => expect(usageLimits.windows[0]?.usedPercent).toBe(12)), + ), + ), + ); + + it.effect("does not use unrelated scopes, API keys, or custom auth deployments", () => + Effect.gen(function* () { + for (const environment of [ + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', + XAI_API_KEY: "different-api-account", + }, + { GROK_AUTH: '{"https://other.example":{"key":"unrelated-token"}}' }, + { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"api-key","auth_mode":"api_key"}}' }, + { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":" "}}' }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', + GROK_OIDC_ISSUER: "https://custom.example", + }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', + GROK_MODELS_BASE_URL: "https://custom.example/v1", + }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', + GROK_OAUTH2_PRINCIPAL_TYPE: "Team", + }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', + GROK_OAUTH2_PRINCIPAL_ID: "team-id", + }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', + GROK_CONFIG: '{"auth_provider_command":"custom-auth"}', + }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', + GROK_CONFIG_PATH: "/custom-config.toml", + }, + ]) { + const { usageLimits: limits } = yield* readGrokAccount({ + HOME: "/definitely/not/a/grok-home", + ...environment, + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request another account's quota")), + ), + ); + expect(limits.windows).toEqual([]); + expect(limits.unavailable?.reason).toBe("unsupported"); + } + }), + ); + + it.effect( + "reports a missing login as unsupported and malformed credentials as a sanitized failure", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + const client = HttpClient.make(() => Effect.die("must not request without credentials")); + const { usageLimits: missing } = yield* readGrokAccount({ HOME: directory }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + ); + expect(missing.unavailable?.reason).toBe("unsupported"); + for (const contents of [ + "private-token-invalid-json", + '{"https://accounts.x.ai/sign-in":{"key":42}}', + ]) { + const { usageLimits: malformed } = yield* readGrokAccount({ + GROK_HOME: directory, + GROK_AUTH: contents, + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + expect(malformed.unavailable).toEqual({ + reason: "probeFailed", + message: "Grok could not read usage limits.", + }); + } + }).pipe(Effect.scoped), + ); + + it.effect( + "does not request subscription limits for custom account or endpoint configuration", + () => + Effect.gen(function* () { + for (const config of [ + '[auth]\nprovider_command = "custom-auth"', + '[grok_com_config]\nissuer = "https://custom.example"', + 'endpoints.proxy = "https://custom.example"', + ]) { + const { usageLimits: limits } = yield* readGrokAccount({ + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}', + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: (path) => { + expect(path.endsWith("config.toml")).toBe(true); + return Effect.succeed(config); + }, + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request another account's quota")), + ), + ); + expect(limits.windows).toEqual([]); + expect(limits.unavailable?.reason).toBe("unsupported"); + } + }), + ); + + it.effect("sanitizes HTTP failures and malformed billing responses, keeping the account", () => + Effect.gen(function* () { + for (const response of [ + new Response("private response", { status: 401 }), + Response.json({ config: { creditUsagePercent: "private-value" } }), + ]) { + const { email, usageLimits: limits } = yield* readGrokAccount({ + HOME: "/definitely/not/a/grok-home", + GROK_AUTH: + '{"https://accounts.x.ai/sign-in":{"key":"private-token","email":"someone@example.com"}}', + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, response)), + ), + ), + ); + expect(email).toBe("someone@example.com"); + expect(limits.windows).toEqual([]); + expect(limits.unavailable).toEqual({ + reason: "probeFailed", + message: "Grok could not read usage limits.", + }); + } + }), + ); +}); + +it.layer(NodeServices.layer)("readGrokAccount email", (it) => { + it.effect("names the account whose limits were read, and nothing for other auth", () => + Effect.gen(function* () { + const current = '"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"'; + const cases = [ + [ + { GROK_AUTH: `{${current}:{"key":"t","email":" Someone@Example.com "}}` }, + "Someone@Example.com", + ], + [ + { + GROK_AUTH: `{${current}:{"key":"t","email":"current@example.com"},"https://accounts.x.ai/sign-in":{"key":"t","email":"legacy@example.com"}}`, + }, + "current@example.com", + ], + [{ GROK_AUTH: `{${current}:{"key":"t"}}` }, undefined], + [ + { + GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com"}}`, + XAI_API_KEY: "api-key", + }, + undefined, + ], + [ + { + GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com","auth_mode":"api_key"}}`, + }, + undefined, + ], + [{ GROK_AUTH: "not-json" }, undefined], + ] as const; + for (const [environment, expected] of cases) { + const { email, usageLimits } = yield* readGrokAccount({ + HOME: "/definitely/not/a/grok-home", + ...environment, + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ config: { creditUsagePercent: 25 } }), + ), + ), + ), + ), + ); + expect(email).toBe(expected); + if (expected) expect(usageLimits.windows[0]?.usedPercent).toBe(25); + } + }), + ); +}); diff --git a/apps/server/src/provider/GrokProvider.ts b/apps/server/src/provider/GrokProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..bab2921e71998b6eea176c081a901e6bf453fcb2 --- /dev/null +++ b/apps/server/src/provider/GrokProvider.ts @@ -0,0 +1,586 @@ +import { + type CustomModelSetting, + type GrokSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderAuth, + type ServerProviderModel, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/compat"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + AUTH_PROBE_TIMEOUT_MS, + buildServerProvider, + COMPACT_SLASH_COMMAND, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "./providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "./providerMaintenance.ts"; +import { + GROK_DEFAULT_MODEL_SLUG, + GROK_SUPPORTED_RUNTIME_MODES, + isValidGrokReasoningEffortToken, + makeGrokAcpRuntime, + resolveGrokAcpBaseModelId, +} from "./acp/GrokAcpSupport.ts"; +import { sessionModelStateFromInitialize } from "./acp/AcpRuntimeModel.ts"; +import { discoverGrokSkills } from "./Drivers/GrokSkills.ts"; + +const GROK_PRESENTATION = { + displayName: "Grok", + supportsConversationRollback: false, + showInteractionModeToggle: false, + supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +// `initialize` is a single local round trip, so this is generous even on slow machines. +const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000; +const GROK_API_KEY_ENV = "XAI_API_KEY"; + +const GROK_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: GROK_DEFAULT_MODEL_SLUG, + name: "Grok Build", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +export function buildInitialGrokProviderSnapshot( + grokSettings: GrokSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = grokModelsFromSettings(grokSettings.customModels); + + if (!grokSettings.enabled) { + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Grok is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Grok CLI availability...", + }, + }); + }); +} + +function grokModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { + readonly options: ReadonlyArray<{ + value: string; + label: string; + description?: string; + isDefault?: boolean; + }>; + readonly currentValue: string | undefined; +} { + const meta = model._meta; + if (!meta || meta.supportsReasoningEffort === false) { + return { options: [], currentValue: undefined }; + } + + const currentEffort = nonEmptyString(meta.reasoningEffort); + const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : []; + const seen = new Set(); + const options: Array<{ + value: string; + label: string; + description?: string; + advertisedDefault: boolean; + }> = []; + + for (const entry of advertisedOptions) { + if (!isRecord(entry)) { + continue; + } + const rawValue = nonEmptyString(entry.value); + const rawId = nonEmptyString(entry.id); + const value = + rawValue && isValidGrokReasoningEffortToken(rawValue) + ? rawValue + : rawId && isValidGrokReasoningEffortToken(rawId) + ? rawId + : undefined; + if (value === undefined || seen.has(value)) { + continue; + } + seen.add(value); + const description = nonEmptyString(entry.description); + options.push({ + value, + label: nonEmptyString(entry.label) ?? value, + ...(description ? { description } : {}), + advertisedDefault: entry.default === true || entry.isDefault === true, + }); + } + + const currentValue = + currentEffort && options.some((option) => option.value === currentEffort) + ? currentEffort + : undefined; + const advertisedDefaults = options.filter((option) => option.advertisedDefault); + const selectedDefault = + advertisedDefaults.find((option) => option.value === currentValue)?.value ?? + advertisedDefaults[0]?.value; + return { + options: options.map(({ value, label, description }) => ({ + value, + label, + ...(description ? { description } : {}), + ...(value === selectedDefault ? { isDefault: true } : {}), + })), + currentValue: currentValue ?? selectedDefault, + }; +} + +export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities { + const reasoning = grokReasoningOptionsFromModel(model); + return reasoning.options.length > 0 + ? createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: reasoning.options.map((option) => ({ + id: option.value, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.isDefault ? { isDefault: true } : {}), + })), + ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}), + }, + ], + }) + : EMPTY_CAPABILITIES; +} + +/** Models advertised by the ACP agent, with the session's current model marked as default. */ +export function buildGrokModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + const currentModelId = modelState.currentModelId.trim(); + const seen = new Set(); + return modelState.availableModels.flatMap((model): ServerProviderModel[] => { + const slug = resolveGrokAcpBaseModelId(model.modelId); + if (!slug || seen.has(slug)) { + return []; + } + seen.add(slug); + return [ + { + slug, + name: model.name.trim() || slug, + isCustom: false, + ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}), + capabilities: buildGrokModelCapabilities(model), + }, + ]; + }); +} + +export interface GrokModelsCliOutput { + /** True or false when the CLI printed a login line, null when it printed neither. */ + readonly authenticated: boolean | null; + readonly models: ReadonlyArray; +} + +/** + * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the + * text is the only signal. Current output looks like: + * + * You are logged in with grok.com. + * Default model: grok-4.6 + * Available models: + * * grok-4.6 (default) + * - grok-4.5 + */ +export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput { + const authenticated = /you are logged in/i.test(output) + ? true + : /not authenticated|not logged in/i.test(output) + ? false + : null; + + const seen = new Set(); + const models: ServerProviderModel[] = []; + for (const line of output.split(/\r?\n/)) { + const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/); + if (!bullet?.[1]) { + continue; + } + const slug = resolveGrokAcpBaseModelId(bullet[1]); + if (seen.has(slug)) { + continue; + } + seen.add(slug); + models.push({ + slug, + name: displayNameFromGrokModelSlug(slug), + isCustom: false, + ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}), + capabilities: EMPTY_CAPABILITIES, + }); + } + return { authenticated, models }; +} + +function displayNameFromGrokModelSlug(slug: string): string { + return slug + .split(/[-_]/g) + .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part)) + .join(" "); +} + +const runGrokCliCommand = ( + grokSettings: GrokSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const command = grokSettings.binaryPath || "grok"; + const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown)); +const decodeAvailableCommand = Schema.decodeUnknownOption( + Schema.Struct({ + name: Schema.String, + description: Schema.String, + input: Schema.optional(Schema.NullOr(Schema.Struct({ hint: Schema.String }))), + }), +); + +export function grokSlashCommandsFromInitialize( + initialized: EffectAcpSchema.InitializeResponse, +): ReadonlyArray { + const commands = decodeAvailableCommands(initialized._meta?.availableCommands); + const byName = new Map([ + [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND], + ]); + for (const entry of Option.getOrElse(commands, () => [])) { + const decoded = decodeAvailableCommand(entry); + if (Option.isNone(decoded)) continue; + const command = decoded.value; + const name = command.name.trim(); + // Permission changes must go through T3 so the client and provider agree. + if (!name || name.toLowerCase() === "always-approve") continue; + // Grok advertises /context, but its ACP handler completes without emitting output. + if (name.toLowerCase() === "context") continue; + const description = command.description.trim(); + const hint = command.input?.hint.trim(); + byName.set(name, { + name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + }); + } + return [...byName.values()]; +} + +/** + * Reads model and command metadata from `initialize._meta`. This never calls `authenticate` + * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers. + */ +const discoverGrokMetadataViaAcpInitialize = ( + grokSettings: GrokSettings, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeGrokAcpRuntime({ + grokSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const initialized = yield* acp.initialize(); + return { + models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)), + slashCommands: grokSlashCommandsFromInitialize(initialized), + }; + }).pipe(Effect.scoped); + +export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( + grokSettings: GrokSettings, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = grokModelsFromSettings(grokSettings.customModels); + + if (!grokSettings.enabled) { + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Grok is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Grok CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Grok CLI (`grok`) is not installed or not on PATH." + : "Failed to execute Grok CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Grok CLI is installed but timed out while running `grok --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Grok CLI is installed but failed to run.", + }, + }); + } + + // `grok models` reports login state and model slugs without starting the agent. + const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe( + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.result, + ); + // Only a clean exit is parsed. Failed invocations print help or error text that + // must not be read as model slugs or as a login verdict. + const modelsOutput = + Result.isSuccess(modelsResult) && + Option.isSome(modelsResult.success) && + modelsResult.success.value.code === 0 + ? modelsResult.success.value + : undefined; + const cliModels: GrokModelsCliOutput = modelsOutput + ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`) + : { authenticated: null, models: [] }; + if (!modelsOutput) { + yield* Effect.logWarning("Grok CLI model listing failed or timed out.", { + errorTag: Result.isFailure(modelsResult) + ? modelsResult.failure._tag + : Option.isNone(modelsResult.success) + ? "Timeout" + : `ExitCode${modelsResult.success.value.code}`, + }); + } + + const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim() + ? { status: "authenticated", type: "api_key", label: "xAI API key" } + : cliModels.authenticated === true + ? { status: "authenticated", type: "cached_token", label: "Grok account" } + : cliModels.authenticated === false + ? { status: "unauthenticated" } + : { status: "unknown" }; + + const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe( + Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })), + Effect.orElseSucceed(() => []), + ); + + const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, environment).pipe( + Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS), + Effect.exit, + ); + const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined; + const acpModels = acpMetadata?.models ?? []; + const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); + if (acpFailed) { + yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", { + errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout", + }); + } + + const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models; + const models = + discoveredModels.length > 0 + ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) + : fallbackModels; + + if (auth.status === "unauthenticated") { + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models, + skills, + probe: { + installed: true, + version, + status: "error", + auth, + message: "Grok CLI is installed but not logged in. Run `grok login`.", + }, + }); + } + + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models, + skills, + slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND], + probe: { + installed: true, + version, + // A failed metadata probe degrades the model picker, it does not make chats fail. + status: acpFailed ? "warning" : "ready", + auth, + ...(acpFailed + ? { + message: + "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", + } + : {}), + }, + }); +}); + +export const enrichGrokSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Grok version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/AcpRegistryCatalog.ts b/apps/server/src/provider/Layers/AcpRegistryCatalog.ts deleted file mode 100644 index 7a2e0d7bcc9553c717cacc5687ff15ad22f51476..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/AcpRegistryCatalog.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import * as ServerConfig from "../../config.ts"; -import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts"; -import * as AcpRegistryRuntimeCoordinator from "../acp/AcpRegistryRuntimeCoordinator.ts"; - -/** Server-lifetime ACP Registry catalog shared by setup, snapshots, and turn launch. */ -export const layer = Layer.merge( - Layer.unwrap( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const path = yield* Path.Path; - return AcpRegistrySupport.AcpRegistryCatalog.layer({ - cacheDir: config.providerStatusCacheDir, - toolsDir: path.join(config.baseDir, "tools"), - }); - }), - ), - AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer, -); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts deleted file mode 100644 index 697eb5badc8c9ff8a65d7d1a96702e3a3f83f500..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/AntigravityProvider.test.ts +++ /dev/null @@ -1,661 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { - ANTIGRAVITY_DEFAULT_MODEL, - AntigravitySettings, - ProviderDriverKind, - ProviderInstanceId, - ProviderSetupError, -} from "@t3tools/contracts"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import * as EffectAcpErrors from "effect-acp/errors"; -import type * as EffectAcpSchema from "effect-acp/compat"; - -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import * as ServerSettings from "../../serverSettings.ts"; -import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; -import { - buildAntigravityModelsFromSession, - makeAntigravityProvider, -} from "./AntigravityProvider.ts"; - -const decodeSettings = Schema.decodeSync(AntigravitySettings); -const instanceId = ProviderInstanceId.make("antigravity-test"); -const driver = ProviderDriverKind.make("antigravity"); - -const initializeResult = { - protocolVersion: 1, - agentCapabilities: { - loadSession: true, - promptCapabilities: { image: true, audio: true, embeddedContext: true }, - sessionCapabilities: { list: {}, resume: {} }, - }, - authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], - agentInfo: { - name: "antigravity-acp", - title: "Google Antigravity", - version: "agy_acp_server_20260818_01_RC01", - }, -} satisfies EffectAcpSchema.InitializeResponse; - -const modelOptions = [ - { value: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" }, - { value: "gemini-3.8-flash-medium", name: "Gemini 3.8 Flash (Medium)" }, - { value: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" }, - { value: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" }, - { value: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" }, - { value: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)" }, - { value: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" }, - { value: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" }, - { value: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" }, - { value: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, - { value: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, -]; - -const modelConfig = { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: "gemini-3.7-flash-high", - options: modelOptions, -} satisfies EffectAcpSchema.SessionConfigOption; - -const sessionSetupResult = { - sessionId: "session-1", - models: { - currentModelId: "gemini-3.7-flash-high", - availableModels: modelOptions.map((option) => ({ modelId: option.value, name: option.name })), - }, - configOptions: [ - modelConfig, - { - id: "mode", - name: "Session Mode", - category: "mode", - type: "select", - currentValue: "default", - options: [ - { value: "default", name: "Default" }, - { value: "yolo", name: "YOLO" }, - ], - }, - ], -} satisfies EffectAcpSchema.NewSessionResponse; - -const started = { - sessionId: "session-1", - initializeResult, - sessionSetupResult, - modelConfigId: "model", -} satisfies AcpSessionRuntimeStartResult; - -const commands = [ - { name: "plan", description: "Create a plan", input: { type: "text", hint: "What to plan" } }, - { name: "logout", description: "Sign out of Google" }, -] satisfies ReadonlyArray; - -const expectedCommands = [ - { name: "plan", description: "Create a plan", input: { hint: "What to plan" } }, - { name: "logout", description: "Sign out of Google" }, -]; - -const layerTest = Layer.merge( - Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - shouldRunScopeWork: () => Effect.succeed(false), - }), - ServerSettings.layerTest(), -); - -type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; - -const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( - options: { readonly enabled?: boolean; readonly safe?: boolean } = {}, -) { - const initialProbe = yield* Deferred.make(); - const probeCalls = yield* Ref.make(0); - const safetyCalls = yield* Ref.make(0); - const probe = yield* Ref.make>( - Deferred.await(initialProbe), - ); - const safety = yield* Ref.make>(Effect.succeed(options.safe ?? true)); - const provider = yield* makeAntigravityProvider( - decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }), - { - stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), - probe: Ref.update(probeCalls, (count) => count + 1).pipe( - Effect.andThen(Ref.get(probe)), - Effect.flatten, - ), - supportsTextGeneration: Ref.update(safetyCalls, (count) => count + 1).pipe( - Effect.andThen(Ref.get(safety)), - Effect.flatten, - ), - }, - ); - const initialUpdate = yield* Stream.toPull( - provider.snapshot.streamChanges.pipe( - Stream.filter((snapshot) => snapshot.installed || snapshot.status === "error"), - ), - ); - const initialize = Deferred.succeed(initialProbe, initializeResult).pipe( - Effect.andThen(initialUpdate), - Effect.asVoid, - ); - return { - provider, - probe, - probeCalls, - safety, - safetyCalls, - initialProbe, - initialUpdate, - initialize, - }; -}); - -describe("Antigravity model catalog", () => { - it("keeps the captured personal catalog's IDs, labels, order, and selected default", () => { - const models = buildAntigravityModelsFromSession(sessionSetupResult); - expect(models.map((model) => [model.slug, model.name])).toEqual( - modelOptions.map((option) => [option.value, option.name]), - ); - expect(models.filter((model) => model.isDefault).map((model) => model.slug)).toEqual([ - "gemini-3.7-flash-high", - ]); - expect( - models - .filter((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) - .map((model) => model.slug), - ).toEqual(["gemini-3.7-flash-high"]); - expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 0)).toBe(true); - expect(models.every((model) => !model.isCustom)).toBe(true); - }); - - it("uses legacy session models only when model config is absent", () => { - const fromLegacy = buildAntigravityModelsFromSession({ - models: sessionSetupResult.models, - }); - expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); - expect( - buildAntigravityModelsFromSession({ - ...sessionSetupResult, - configOptions: [{ ...modelConfig, options: [] }], - }), - ).toEqual([]); - }); - - it("flattens native option groups without combining distinct model IDs", () => { - const models = buildAntigravityModelsFromSession({ - configOptions: [ - { - ...modelConfig, - currentValue: "gemini-pro-agent", - options: [ - { groupId: "Flash", name: "Flash", options: [modelOptions[3]!, modelOptions[4]!] }, - { groupId: "Pro", name: "Pro", options: [modelOptions[9]!, modelOptions[3]!] }, - ], - }, - ], - }); - expect(models.map((model) => model.slug)).toEqual([ - "gemini-3.7-flash-high", - "gemini-3.7-flash-medium", - "gemini-pro-agent", - ]); - expect(models.find((model) => model.isDefault)?.slug).toBe("gemini-pro-agent"); - expect(models.find((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))?.slug).toBe( - "gemini-pro-agent", - ); - }); -}); - -it.layer(layerTest)("Antigravity provider snapshots", (it) => { - it.effect("does not probe or run helper safety checks while disabled", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness({ enabled: false }); - const snapshot = yield* harness.provider.snapshot.refresh; - expect(snapshot).toMatchObject({ - enabled: false, - installed: false, - status: "disabled", - auth: { status: "unknown" }, - models: [], - setup: { canAuthenticate: true, canInstall: true }, - showInteractionModeToggle: false, - supportsConversationRollback: false, - supportsTextGeneration: false, - }); - expect(yield* Ref.get(harness.probeCalls)).toBe(0); - expect(yield* Ref.get(harness.safetyCalls)).toBe(0); - }), - ), - ); - - it.effect("records explicit sign-in while disabled without starting a health probe", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness({ enabled: false }); - const signedIn = yield* Stream.toPull( - harness.provider.snapshot.streamChanges.pipe( - Stream.filter( - (snapshot) => - snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, - ), - ), - ); - yield* harness.provider.onSessionStarted(started); - yield* harness.provider.onAvailableCommands(commands); - const [snapshot] = yield* signedIn; - expect(snapshot).toMatchObject({ - enabled: false, - installed: true, - status: "disabled", - auth: { status: "authenticated", type: "oauth-personal" }, - workspaceSnapshots: [], - }); - expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); - expect(snapshot.slashCommands).toEqual(expectedCommands); - expect((yield* harness.provider.snapshot.refresh).models).toEqual(snapshot.models); - expect(yield* Ref.get(harness.probeCalls)).toBe(0); - }), - ), - ); - - it.effect("publishes the configured sign-in method before any account is checked", () => - Effect.scoped( - Effect.gen(function* () { - const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), { - stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), - probe: Effect.succeed(initializeResult), - supportsTextGeneration: Effect.succeed(true), - auth: { type: "gemini-api-key", label: "Gemini API key" }, - }); - expect((yield* provider.snapshot.getSnapshot).auth).toEqual({ - status: "unknown", - type: "gemini-api-key", - }); - }), - ), - ); - - it.effect("treats initialize as installation proof, not account or model discovery", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ - installed: false, - status: "warning", - auth: { status: "unknown" }, - models: [], - }); - yield* harness.initialize; - const snapshot = yield* harness.provider.snapshot.getSnapshot; - expect(snapshot).toMatchObject({ - installed: true, - status: "warning", - version: "agy_acp_server_20260818_01_RC01", - auth: { status: "unknown" }, - models: [], - }); - expect(yield* Ref.get(harness.probeCalls)).toBe(1); - }), - ), - ); - - it.effect("publishes session metadata and native commands without another health probe", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - const nextReady = yield* Stream.toPull( - harness.provider.snapshot.streamChanges.pipe( - Stream.filter( - (snapshot) => - snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, - ), - ), - ); - yield* harness.provider.onSessionStarted(started, "/workspace"); - yield* harness.provider.onAvailableCommands(commands, "/workspace"); - const [snapshot] = yield* nextReady; - expect(snapshot).toMatchObject({ - status: "ready", - auth: { status: "authenticated", type: "oauth-personal" }, - supportsTextGeneration: true, - }); - expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); - expect(snapshot.slashCommands).toEqual(expectedCommands); - expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual( - expectedCommands, - ); - expect(yield* Ref.get(harness.probeCalls)).toBe(1); - }), - ), - ); - - it.effect("does not retain a disposable sign-in workspace", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - yield* harness.provider.onSessionStarted(started); - yield* harness.provider.onAvailableCommands(commands); - const snapshot = yield* harness.provider.snapshot.getSnapshot; - expect(snapshot.models).toHaveLength(11); - expect(snapshot.slashCommands).toEqual(expectedCommands); - expect(snapshot.workspaceSnapshots).toEqual([]); - }), - ), - ); - - it.effect("clears all account metadata on sign-out and authentication failure", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - for (const clear of [harness.provider.onSignedOut, harness.provider.onAuthRequired]) { - yield* harness.provider.onSessionStarted(started, "/workspace"); - yield* harness.provider.onAvailableCommands(commands, "/workspace"); - yield* clear; - expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ - installed: true, - status: "warning", - auth: { status: "unauthenticated" }, - models: [], - slashCommands: [], - skills: [], - workspaceSnapshots: [], - supportsTextGeneration: false, - }); - yield* harness.provider.onAvailableCommands(commands, "/workspace"); - yield* harness.provider.onConfigOptionsUpdated([modelConfig]); - expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]); - expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); - } - const refreshed = yield* harness.provider.snapshot.refresh; - expect(refreshed.auth.status).toBe("unauthenticated"); - expect(refreshed.supportsTextGeneration).toBe(false); - }), - ), - ); - - it.effect("replaces live model choices and accepts an empty catalog", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - yield* harness.provider.onSessionStarted(started, "/workspace"); - yield* harness.provider.onAvailableCommands(commands, "/workspace"); - const before = yield* harness.provider.snapshot.getSnapshot; - const configOptions = [ - { - ...modelConfig, - currentValue: "gemini-3.8-flash-high", - options: modelOptions.slice(0, 3), - }, - ]; - const nextSnapshot = yield* Stream.toPull( - harness.provider.snapshot.streamChanges.pipe( - Stream.filter((snapshot) => snapshot.models.length === 3), - ), - ); - yield* harness.provider.onConfigOptionsUpdated(configOptions); - expect((yield* nextSnapshot)[0]).toMatchObject({ - models: buildAntigravityModelsFromSession({ configOptions }), - auth: before.auth, - workspaceSnapshots: before.workspaceSnapshots, - slashCommands: expectedCommands, - }); - yield* harness.provider.onConfigOptionsUpdated([]); - expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); - }), - ), - ); - - it.effect("replaces one account's catalog instead of combining accounts", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - yield* harness.provider.onSessionStarted(started); - yield* harness.provider.onSignedOut; - yield* harness.provider.onSessionStarted({ - ...started, - sessionSetupResult: { - sessionId: "new-account-session", - configOptions: [ - { ...modelConfig, currentValue: "gemini-pro-agent", options: [modelOptions[9]!] }, - ], - }, - }); - expect( - (yield* harness.provider.snapshot.getSnapshot).models.map((model) => model.slug), - ).toEqual(["gemini-pro-agent"]); - }), - ), - ); - - it.effect("retains known account metadata when a local health check fails", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - yield* harness.provider.onSessionStarted(started, "/workspace"); - yield* harness.provider.onAvailableCommands(commands, "/workspace"); - yield* Ref.set( - harness.probe, - Effect.fail(EffectAcpErrors.AcpRequestError.internalError("probe failed")), - ); - const snapshot = yield* harness.provider.snapshot.refresh; - expect(snapshot).toMatchObject({ - installed: true, - status: "error", - auth: { status: "authenticated" }, - }); - expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); - expect(snapshot.slashCommands).toEqual(expectedCommands); - expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace"); - }), - ), - ); - - it.effect("allows a slow packaged runtime health check to finish", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - const entered = yield* Deferred.make(); - const initialized = yield* Deferred.make(); - yield* Ref.set( - harness.probe, - Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(initialized))), - ); - - const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); - yield* Deferred.await(entered); - yield* TestClock.adjust("47 seconds"); - yield* Deferred.succeed(initialized, initializeResult); - const snapshot = yield* Fiber.join(refresh); - - expect(snapshot).toMatchObject({ - installed: true, - status: "warning", - auth: { status: "unknown" }, - }); - }), - ), - ); - - it.effect("closes a stalled health probe at its deadline", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - const entered = yield* Deferred.make(); - const closed = yield* Deferred.make(); - yield* Ref.set( - harness.probe, - Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Effect.never), - Effect.ensuring(Deferred.succeed(closed, undefined)), - ), - ); - const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); - yield* Deferred.await(entered); - yield* TestClock.adjust("90 seconds"); - const snapshot = yield* Fiber.join(refresh); - yield* Deferred.await(closed); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).toContain("90 seconds"); - }), - ), - ); - - it.effect("distinguishes missing executables from a failed installed executable", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - const failures = [ - { - error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "ENOENT" } }), - installed: false, - }, - { - error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "EACCES" } }), - installed: true, - }, - { - error: new ProviderSetupError({ - instanceId, - operation: "resolve", - detail: "Antigravity is not installed.", - }), - installed: false, - }, - ]; - for (const { error, installed } of failures) { - yield* harness.provider.onSessionStarted(started, "/workspace"); - yield* harness.provider.onAvailableCommands(commands, "/workspace"); - yield* Ref.set(harness.probe, Effect.fail(error)); - const snapshot = yield* harness.provider.snapshot.refresh; - expect(snapshot).toMatchObject({ - installed, - status: "error", - auth: { status: "authenticated" }, - }); - expect(snapshot.models).toHaveLength(installed ? 11 : 0); - expect(snapshot.slashCommands).toHaveLength(installed ? 2 : 0); - expect(snapshot.workspaceSnapshots).toHaveLength(installed ? 1 : 0); - expect(snapshot.supportsTextGeneration).toBe(installed); - } - }), - ), - ); - - it.effect("does not let an old health result restore a signed-out account", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - yield* harness.provider.onSessionStarted(started); - const releaseProbe = yield* Deferred.make(); - const probeEntered = yield* Deferred.make(); - yield* Ref.set( - harness.probe, - Deferred.succeed(probeEntered, undefined).pipe( - Effect.andThen(Deferred.await(releaseProbe)), - ), - ); - const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); - yield* Deferred.await(probeEntered); - yield* harness.provider.onSignedOut; - yield* Deferred.succeed(releaseProbe, initializeResult); - const snapshot = yield* Fiber.join(refresh); - expect(snapshot).toMatchObject({ - auth: { status: "unauthenticated" }, - models: [], - supportsTextGeneration: false, - }); - }), - ), - ); - - it.effect("exposes helper support only when the supplied safety check allows it", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness({ safe: false }); - yield* harness.initialize; - yield* harness.provider.onSessionStarted(started); - expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(false); - yield* Ref.set(harness.safety, Effect.succeed(true)); - yield* harness.provider.snapshot.refresh; - expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(true); - }), - ), - ); - - it.effect("keeps discovered workspace skills through session and command updates", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - const skills = [ - { - name: "deploy", - description: "Ship it", - path: "/workspace/.agent/skills/deploy", - enabled: true, - }, - ]; - const discovered = yield* harness.provider.snapshotForCwd("/workspace", skills); - expect(discovered.skills).toEqual(skills); - yield* harness.provider.onSessionStarted(started, "/workspace"); - yield* harness.provider.onAvailableCommands(commands, "/workspace"); - const after = yield* harness.provider.snapshot.getSnapshot; - expect( - after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills, - ).toEqual(skills); - expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills); - - const rescanned = [ - ...skills, - { name: "review", path: "/workspace/.agent/skills/review", enabled: true }, - ]; - yield* harness.provider.snapshotForCwd("/workspace", rescanned); - yield* harness.provider.onSessionStarted(started, "/workspace"); - expect( - (yield* harness.provider.snapshot.getSnapshot).workspaceSnapshots?.find( - (entry) => entry.cwd === "/workspace", - )?.skills, - ).toEqual(rescanned); - }), - ), - ); - - it.effect("bounds workspace metadata without starting sessions for workspace lookup", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - yield* harness.provider.onSessionStarted(started); - for (let index = 0; index < 35; index++) { - yield* harness.provider.onAvailableCommands(commands, `/workspace-${index}`); - } - const snapshot = yield* harness.provider.snapshotForCwd("/workspace-34"); - expect(snapshot.workspaceSnapshots).toHaveLength(32); - expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace-3"); - expect(snapshot.slashCommands).toEqual(expectedCommands); - expect(yield* Ref.get(harness.probeCalls)).toBe(1); - }), - ), - ); -}); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts deleted file mode 100644 index 9f850c6f0e04654834805ff171e64f0b61e90e6f..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { - ANTIGRAVITY_DEFAULT_MODEL, - ProviderDriverKind, - type AntigravitySettings, - type ProviderSetupError, - type ServerProvider, - type ServerProviderModel, - type ServerProviderSlashCommand, -} from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Predicate from "effect/Predicate"; -import * as Result from "effect/Result"; -import * as Stream from "effect/Stream"; -import * as SubscriptionRef from "effect/SubscriptionRef"; -import type * as EffectAcpErrors from "effect-acp/errors"; -import type * as EffectAcpSchema from "effect-acp/compat"; - -import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { - makeManualOnlyProviderMaintenanceCapabilities, - type ProviderMaintenanceCapabilities, -} from "../providerMaintenance.ts"; -import { - buildServerProvider, - isCommandMissingCause, - type ServerProviderDraft, -} from "../providerSnapshot.ts"; - -const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); -const MAX_WORKSPACE_SNAPSHOTS = 32; -const HEALTH_CHECK_TIMEOUT = "90 seconds"; -const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; -const AUTH_UNCHECKED_MESSAGE = - "Antigravity is installed. Google account access is not checked yet."; - -type SessionSetupResult = Pick< - AcpSessionRuntimeStartResult["sessionSetupResult"], - "configOptions" | "models" ->; - -/** Keep the native model IDs, including model-specific thinking levels. */ -export function buildAntigravityModelsFromSession( - setup: SessionSetupResult, -): ReadonlyArray { - const config = setup.configOptions?.find( - (option) => option.id === "model" || option.category === "model", - ); - const currentValue = - config?.type === "select" ? config.currentValue : setup.models?.currentModelId; - const entries = - config?.type === "select" - ? config.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)) - : config === undefined - ? (setup.models?.availableModels.map((model) => ({ - value: model.modelId, - name: model.name, - })) ?? []) - : []; - const seen = new Set(); - return entries.flatMap((entry): ServerProviderModel[] => { - if (!entry.value.trim() || seen.has(entry.value)) return []; - seen.add(entry.value); - return [ - { - slug: entry.value, - name: entry.name.trim() ? entry.name : entry.value, - isCustom: false, - ...(entry.value === currentValue - ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] } - : {}), - capabilities: EMPTY_MODEL_CAPABILITIES, - }, - ]; - }); -} - -function nativeCommands( - commands: ReadonlyArray, -): ReadonlyArray { - const seen = new Set(); - return commands.flatMap((command): ServerProviderSlashCommand[] => { - if (!command.name.trim() || seen.has(command.name)) return []; - seen.add(command.name); - const description = command.description.trim(); - const hint = command.input?.hint.trim(); - return [ - { - name: command.name, - ...(description ? { description } : {}), - ...(hint ? { input: { hint } } : {}), - }, - ]; - }); -} - -function isMissingInstallation(error: EffectAcpErrors.AcpError | ProviderSetupError): boolean { - if (error._tag === "AcpSpawnError") { - return ( - isCommandMissingCause(error.cause) || - (Predicate.isObject(error.cause) && error.cause.code === "ENOENT") - ); - } - return ( - error._tag === "ProviderSetupError" && - error.operation === "resolve" && - /not installed|missing|incomplete|does not publish/i.test(error.detail) - ); -} - -interface AntigravityProviderState { - readonly draft: ServerProviderDraft; - readonly authRevision: number; -} - -interface AntigravityProviderOptions { - readonly stampIdentity: (snapshot: ServerProviderDraft) => Effect.Effect; - readonly probe: Effect.Effect< - EffectAcpSchema.InitializeResponse, - EffectAcpErrors.AcpError | ProviderSetupError - >; - readonly supportsTextGeneration: Effect.Effect; - readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; - /** Auth type and label published once a session authenticates. */ - readonly auth?: { readonly type: string; readonly label: string }; -} - -/** Health uses initialize only. Session callbacks supply account-specific metadata. */ -export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* ( - settings: AntigravitySettings, - options: AntigravityProviderOptions, -) { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const initialDraft = { - ...buildServerProvider({ - presentation: { displayName: "Antigravity", showInteractionModeToggle: false }, - enabled: settings.enabled, - checkedAt, - models: [], - probe: { - installed: false, - version: null, - status: "warning", - // The configured method rides along so the registry can tell a saved - // account for this method from one left by a previous configuration. - auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) }, - message: settings.enabled - ? "Checking Antigravity availability." - : "Antigravity is disabled in T3 Code settings.", - }, - }), - setup: { canAuthenticate: true, canInstall: true }, - supportsConversationRollback: false, - supportsTextGeneration: false, - workspaceSnapshots: [], - } satisfies ServerProviderDraft; - const metadata = yield* SubscriptionRef.make({ - draft: initialDraft, - authRevision: 0, - }); - // Skills the driver discovered on disk per workspace. Session callbacks - // rewrite the workspace entry with native commands and must keep these, or - // the registry drops the suggestions and never re-reads the workspace. - const discoveredSkills = new Map(); - const getSnapshot = SubscriptionRef.get(metadata).pipe( - Effect.flatMap((state) => options.stampIdentity(state.draft)), - ); - - const checkProvider = Effect.fn("checkAntigravityProvider")(function* () { - if (!settings.enabled) return yield* getSnapshot; - const before = yield* SubscriptionRef.get(metadata); - const result = yield* options.probe.pipe( - Effect.timeoutOption(HEALTH_CHECK_TIMEOUT), - Effect.result, - ); - const initialized = - Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined; - const failure = Result.isFailure(result) ? result.failure : undefined; - const missingInstallation = failure !== undefined && isMissingInstallation(failure); - const errorMessage = - initialized !== undefined - ? undefined - : failure?._tag === "ProviderSetupError" - ? failure.detail.trim() || "Antigravity could not complete its local health check." - : missingInstallation - ? "Antigravity is not installed or its executable could not be found." - : failure - ? "Antigravity could not complete its local health check." - : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; - const supportsTextGeneration = - initialized !== undefined ? yield* options.supportsTextGeneration : false; - const updatedAt = DateTime.formatIso(yield* DateTime.now); - const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { - if (state.authRevision !== before.authRevision) return state; - const { message: _previousMessage, ...draft } = state.draft; - const authenticated = draft.auth.status === "authenticated"; - const message = - errorMessage ?? - (authenticated - ? undefined - : draft.auth.status === "unauthenticated" - ? SIGN_IN_MESSAGE - : AUTH_UNCHECKED_MESSAGE); - return { - ...state, - draft: { - ...draft, - installed: !missingInstallation, - version: initialized?.agentInfo?.version || draft.version, - status: errorMessage ? "error" : authenticated ? "ready" : "warning", - checkedAt: updatedAt, - ...(missingInstallation - ? { - models: [], - slashCommands: [], - skills: [], - workspaceSnapshots: [], - supportsTextGeneration: false, - } - : {}), - ...(initialized !== undefined - ? { - supportsTextGeneration: - supportsTextGeneration && draft.auth.status !== "unauthenticated", - } - : {}), - ...(message ? { message } : {}), - }, - } satisfies AntigravityProviderState; - }); - return yield* options.stampIdentity(next.draft); - }); - - const maintenanceCapabilities = - options.maintenanceCapabilities ?? - makeManualOnlyProviderMaintenanceCapabilities({ - provider: ProviderDriverKind.make("antigravity"), - packageName: null, - }); - const managed = yield* makeManagedServerProvider({ - resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), - getSettings: Effect.succeed(settings), - streamSettings: Stream.empty, - haveSettingsChanged: () => false, - initialSnapshot: () => getSnapshot, - checkProvider: checkProvider(), - enrichSnapshot: ({ publishSnapshot }) => - SubscriptionRef.changes(metadata).pipe( - Stream.runForEach((state) => - options.stampIdentity(state.draft).pipe(Effect.flatMap(publishSnapshot)), - ), - ), - }); - - const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( - started: AcpSessionRuntimeStartResult, - cwd?: string, - ) { - const before = yield* SubscriptionRef.get(metadata); - const supportsTextGeneration = yield* options.supportsTextGeneration; - const updatedAt = DateTime.formatIso(yield* DateTime.now); - yield* SubscriptionRef.update(metadata, (state) => { - if ( - state.authRevision !== before.authRevision && - state.draft.auth.status === "unauthenticated" - ) { - return state; - } - const { message: _previousMessage, ...draft } = state.draft; - const workspaces = draft.workspaceSnapshots ?? []; - const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined; - return { - authRevision: state.authRevision + 1, - draft: { - ...draft, - installed: true, - status: settings.enabled ? "ready" : "disabled", - version: started.initializeResult.agentInfo?.version || draft.version, - auth: { - status: "authenticated", - type: options.auth?.type ?? "oauth-personal", - label: options.auth?.label ?? "Google account", - }, - checkedAt: updatedAt, - models: buildAntigravityModelsFromSession(started.sessionSetupResult), - supportsTextGeneration, - ...(cwd - ? { - workspaceSnapshots: [ - ...workspaces.filter((entry) => entry.cwd !== cwd), - { - cwd, - checkedAt: updatedAt, - slashCommands: workspace?.slashCommands ?? draft.slashCommands, - skills: workspace?.skills ?? discoveredSkills.get(cwd) ?? [], - }, - ].slice(-MAX_WORKSPACE_SNAPSHOTS), - } - : {}), - }, - } satisfies AntigravityProviderState; - }); - }); - - const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* ( - configOptions: ReadonlyArray, - ) { - const models = buildAntigravityModelsFromSession({ configOptions }); - yield* SubscriptionRef.update(metadata, (state) => { - if (state.draft.auth.status !== "authenticated") return state; - return { ...state, draft: { ...state.draft, models } }; - }); - }); - - const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* ( - commands: ReadonlyArray, - cwd?: string, - ) { - const slashCommands = nativeCommands(commands); - const updatedAt = DateTime.formatIso(yield* DateTime.now); - yield* SubscriptionRef.update(metadata, (state) => { - if (state.draft.auth.status === "unauthenticated") return state; - return { - ...state, - draft: { - ...state.draft, - slashCommands, - ...(cwd - ? { - workspaceSnapshots: [ - ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd), - { - cwd, - checkedAt: updatedAt, - slashCommands, - skills: - state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.skills ?? - discoveredSkills.get(cwd) ?? - [], - }, - ].slice(-MAX_WORKSPACE_SNAPSHOTS), - } - : {}), - }, - }; - }); - }); - - const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { - const updatedAt = DateTime.formatIso(yield* DateTime.now); - yield* SubscriptionRef.update( - metadata, - (state) => - ({ - authRevision: state.authRevision + 1, - draft: { - ...state.draft, - auth: { status: "unauthenticated" }, - status: settings.enabled ? "warning" : "disabled", - message: SIGN_IN_MESSAGE, - checkedAt: updatedAt, - models: [], - slashCommands: [], - skills: [], - workspaceSnapshots: [], - supportsTextGeneration: false, - }, - }) satisfies AntigravityProviderState, - ); - discoveredSkills.clear(); - }); - - const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( - cwd: string, - skills?: ServerProvider["skills"], - ) { - if (skills) { - discoveredSkills.set(cwd, skills); - // A rescan replaces the stored entry's skills. Session callbacks and - // health checks republish that entry, so it must not keep old ones. - yield* SubscriptionRef.update(metadata, (state) => - state.draft.workspaceSnapshots?.some((entry) => entry.cwd === cwd) - ? { - ...state, - draft: { - ...state.draft, - workspaceSnapshots: state.draft.workspaceSnapshots.map((entry) => - entry.cwd === cwd ? { ...entry, skills } : entry, - ), - }, - } - : state, - ); - } - const snapshot = yield* getSnapshot; - const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd); - const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? []; - return workspace - ? { ...snapshot, slashCommands: workspace.slashCommands, skills: resolvedSkills } - : { ...snapshot, skills: resolvedSkills }; - }); - - return { - snapshot: { ...managed, getSnapshot }, - onSessionStarted, - onConfigOptionsUpdated, - onAvailableCommands, - onSignedOut: clearAccountMetadata(), - onAuthRequired: clearAccountMetadata(), - snapshotForCwd, - }; -}); diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts deleted file mode 100644 index 5c8734480af9c151c852ac3791892a26cf495980..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. -import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; -import { vi } from "vite-plus/test"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; -import * as TestClock from "effect/testing/TestClock"; -import { - ClaudeSettings, - ProviderDriverKind, - ProviderInstanceId, - type ServerProvider, -} from "@t3tools/contracts"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -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 { - buildClaudeCapabilitiesProbeQueryOptions, - CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, - probeClaudeCapabilities, - probeClaudeWorkspaceSnapshot, -} from "./ClaudeProvider.ts"; -import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; - -vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); - -const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); - -it("isolates Claude capability probes without dropping workspace setting sources", () => { - const abortController = new AbortController(); - const options = buildClaudeCapabilitiesProbeQueryOptions({ - executablePath: "/usr/bin/claude", - abortController, - environment: { - HOME: "/home/user", - ENABLE_CLAUDEAI_MCP_SERVERS: "true", - FORCE_CODE_TERMINAL: "1", - }, - cwd: "/workspace/project", - }); - - assert.deepEqual(options.mcpServers, {}); - assert.equal(options.strictMcpConfig, true); - assert.equal(options.cwd, "/workspace/project"); - assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); - assert.deepEqual(options.settings, { disableAllHooks: true }); - assert.deepEqual(options.allowedTools, []); - assert.equal(options.persistSession, false); - assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude"); - assert.equal(options.abortController, abortController); - assert.equal(options.env?.HOME, "/home/user"); - assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); - assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined); - assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0"); - assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1"); -}); - -it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { - it.effect( - "discovers commands and skills separately for each cwd without replacing machine metadata", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")]; - for (const cwd of workspaces) { - const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); - yield* fs.makeDirectory(skillDir, { recursive: true }); - yield* fs.writeFileString( - path.join(skillDir, "SKILL.md"), - "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", - ); - } - const machineSnapshot = { - instanceId: ProviderInstanceId.make("claude"), - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - installed: true, - status: "ready", - auth: { status: "authenticated", email: "machine@example.com" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "2.1.288", - models: [], - slashCommands: [{ name: "server-cwd-only" }], - skills: [], - } satisfies ServerProvider; - let usageCalls = 0; - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { - assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir); - assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance"); - return { - initializationResult: async () => ({ - account: { email: "workspace@example.com" }, - commands: [ - { - name: options?.cwd === workspaces[0] ? "start-session" : "other-project", - description: "Project command", - argumentHint: "[topic]", - }, - { name: "nested:review", description: "Review changes", argumentHint: "" }, - { name: "NESTED:review", description: "", argumentHint: "[path]" }, - { name: "compact", description: "Provider compact", argumentHint: "" }, - { name: "user-command", description: "Existing user command", argumentHint: "" }, - ], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { - usageCalls++; - return { rate_limits_available: false, rate_limits: null }; - }, - } as ReturnType; - }); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - for (const [index, cwd] of workspaces.entries()) { - const scoped = yield* probeClaudeWorkspaceSnapshot( - decodeClaudeSettings({ homePath: configDir }), - machineSnapshot, - cwd, - { ...process.env, T3_WORKSPACE_PROBE: "owned-instance" }, - ); - assert.deepEqual(scoped, { - ...machineSnapshot, - slashCommandsPending: false, - slashCommands: [ - COMPACT_SLASH_COMMAND, - { - name: index === 0 ? "start-session" : "other-project", - description: "Project command", - input: { hint: "[topic]" }, - }, - { name: "nested:review", description: "Review changes", input: { hint: "[path]" } }, - { name: "user-command", description: "Existing user command" }, - ], - skills: [ - { - name: "existing-skill", - path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"), - enabled: true, - scope: "project", - description: "Existing project skill", - }, - ], - }); - } - assert.deepEqual( - query.mock.calls.map(([input]) => input.options?.cwd), - workspaces, - ); - assert.equal(usageCalls, 0); - }).pipe(Effect.scoped), - ); - - it.effect("keeps readable skills during failed command discovery and recovers on retry", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" }); - const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); - yield* fs.makeDirectory(skillDir, { recursive: true }); - yield* fs.writeFileString( - path.join(skillDir, "SKILL.md"), - "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", - ); - const machineSnapshot = { - instanceId: ProviderInstanceId.make("claude"), - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - installed: true, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "2.1.288", - models: [], - slashCommands: [{ name: "server-cwd-only" }], - skills: [], - } satisfies ServerProvider; - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: () => - Promise.reject( - new Error("Initialization failed"), - ), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ - rate_limits_available: false, - rate_limits: null, - }), - }) as ReturnType, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const settings = decodeClaudeSettings({ homePath: cwd }); - const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); - assert.deepEqual(failed, { - ...machineSnapshot, - slashCommands: [COMPACT_SLASH_COMMAND], - slashCommandsPending: true, - skills: [ - { - name: "existing-skill", - path: path.join(skillDir, "SKILL.md"), - enabled: true, - scope: "project", - description: "Existing project skill", - }, - ], - }); - query.mockImplementation( - () => - ({ - initializationResult: async () => ({ - commands: [{ name: "recovered", description: "", argumentHint: "" }], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ - rate_limits_available: false, - rate_limits: null, - }), - }) as ReturnType, - ); - const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); - assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]); - assert.equal(recovered.status, "ready"); - assert.equal(recovered.slashCommandsPending, false); - assert.deepEqual(recovered.skills, failed.skills); - const disabled = yield* probeClaudeWorkspaceSnapshot( - { ...settings, enabled: false }, - machineSnapshot, - cwd, - ); - assert.equal(disabled, machineSnapshot); - assert.equal(query.mock.calls.length, 2); - }).pipe(Effect.scoped), - ); - - it.effect("serializes strict no-MCP options and still resolves account capabilities", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); - const executablePath = yield* path.fromFileUrl( - new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url), - ); - const invocationPath = path.join(tempDir, "invocation.json"); - // The probe aborts the SDK without awaiting the child's exit, and on - // Windows a directory that is still some process's cwd cannot be - // removed. Keep the workspace outside the scoped directory and let it - // go with a retrying removal once the child has gone. - const workspaceCwd = yield* fs.makeTempDirectory({ prefix: "t3-claude-probe-cwd-" }); - // Node's own retry rather than an Effect schedule: it.effect runs on a - // TestClock, so a scheduled retry would wait for time nobody advances. - // If the child still holds the directory after that, an empty temp - // directory is left behind rather than failing the test for it. - yield* Effect.addFinalizer(() => - Effect.promise(() => - NodeFSP.rm(workspaceCwd, { - recursive: true, - force: true, - maxRetries: 20, - retryDelay: 250, - }).catch(() => undefined), - ), - ); - - const capabilities = yield* probeClaudeCapabilities( - decodeClaudeSettings({ binaryPath: executablePath }), - { - ...process.env, - T3_PROBE_INVOCATION_PATH: invocationPath, - ENABLE_CLAUDEAI_MCP_SERVERS: "true", - }, - workspaceCwd, - ); - - assert.deepEqual(capabilities, { - email: "dev@example.com", - subscriptionType: "pro", - tokenSource: "oauth", - apiProvider: undefined, - slashCommands: [ - { - name: "review", - description: "Review changes", - input: { hint: "[path]" }, - }, - ], - usage: { - rate_limits_available: true, - rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, - }, - }); - - // @effect-diagnostics-next-line preferSchemaOverJson:off - const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { - readonly args: ReadonlyArray; - readonly cwd: string; - readonly connectorEnv: string; - readonly mcpConfig: unknown; - }; - assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd)); - assert.equal(invocation.connectorEnv, "false"); - assert.equal(invocation.args.includes("--strict-mcp-config"), true); - assert.equal(invocation.args.includes("--mcp-config"), false); - assert.equal(invocation.mcpConfig, undefined); - - assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true); - - const settingsFlagIndex = invocation.args.indexOf("--settings"); - assert.notEqual(settingsFlagIndex, -1); - // @effect-diagnostics-next-line preferSchemaOverJson:off - const flagSettings = JSON.parse(invocation.args[settingsFlagIndex + 1] ?? "{}") as { - readonly disableAllHooks?: boolean; - }; - assert.equal(flagSettings.disableAllHooks, true); - }).pipe(Effect.scoped), - ); -}); - -it.effect("preserves initialized capabilities when optional usage times out", () => - Effect.gen(function* () { - const usageStarted = yield* Deferred.make(); - let abortSignal: AbortSignal | undefined; - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { - abortSignal = options?.abortController?.signal; - return { - initializationResult: async () => ({ - account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, - commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => { - Deferred.doneUnsafe(usageStarted, Effect.void); - return new Promise(() => {}); - }, - } as ReturnType; - }); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const probe = yield* probeClaudeCapabilities( - decodeClaudeSettings({ binaryPath: "claude" }), - ).pipe(Effect.forkChild); - yield* Deferred.await(usageStarted); - yield* TestClock.adjust("4 seconds"); - const capabilities = yield* Fiber.join(probe); - assert.equal(capabilities?.email, "dev@example.com"); - assert.equal(capabilities?.subscriptionType, "pro"); - assert.equal(capabilities?.tokenSource, "oauth"); - assert.deepEqual(capabilities?.slashCommands, [ - { name: "review", description: "Review changes", input: { hint: "[path]" } }, - ]); - assert.equal(capabilities?.usage, undefined); - assert.equal(abortSignal?.aborted, true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts deleted file mode 100644 index a1b7f2494641954955a42d89c24fe2aadd39f803..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ /dev/null @@ -1,675 +0,0 @@ -import { - type ClaudeSettings, - type ModelCapabilities, - type ServerProvider, - type ServerProviderSlashCommand, - type ServerProviderResetCredits, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { createModelCapabilities } from "@t3tools/shared/model"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { - query as claudeQuery, - type Options as ClaudeQueryOptions, - type SlashCommand as ClaudeSlashCommand, - type SDKControlGetUsageResponse, - type SDKUserMessage, - type SettingSource, -} from "@anthropic-ai/claude-agent-sdk"; - -import { - buildServerProvider, - COMPACT_SLASH_COMMAND, - DEFAULT_TIMEOUT_MS, - isCommandMissingCause, - parseGenericCliVersion, - providerModelsFromSettings, - spawnAndCollect, - type ServerProviderDraft, -} from "../providerSnapshot.ts"; -import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; -import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; -import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; -import type { ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; -import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; -import { - type ClaudeScopedLimitNames, - claudeUsageResponseToLimits, - recordClaudeUsageResponse, -} from "./claudeUsageLimits.ts"; -import { - BUNDLED_CLAUDE_MODEL_CATALOG, - type ClaudeModelCatalog, - formatClaudeVersionUpgradeMessage, - resolveClaudeModelsForVersion, -} from "../ClaudeModelCatalog.ts"; - -const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); - -const CLAUDE_PRESENTATION = { - displayName: "Claude", - showInteractionModeToggle: true, - reportsContextWindow: true, -} as const; -function toTitleCaseWords(value: string): string { - const parts: Array = []; - for (const part of value.split(/[\s_-]+/g)) { - if (part.length > 0) { - parts.push(part[0]!.toUpperCase() + part.slice(1).toLowerCase()); - } - } - return parts.join(" "); -} - -function claudeSubscriptionLabel(subscriptionType: string | undefined): string | undefined { - const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); - if (!normalized) return undefined; - - switch (normalized) { - case "claudemaxsubscription": - return "Max"; - case "claudemax5xsubscription": - return "Max 5x"; - case "claudemax20xsubscription": - return "Max 20x"; - case "claudeenterprisesubscription": - return "Enterprise"; - case "claudeteamsubscription": - return "Team"; - case "claudeprosubscription": - return "Pro"; - case "claudefreesubscription": - return "Free"; - case "max": - case "maxplan": - return "Max"; - case "max5": - return "Max 5x"; - case "max20": - return "Max 20x"; - case "enterprise": - return "Enterprise"; - case "team": - return "Team"; - case "pro": - return "Pro"; - case "free": - return "Free"; - default: - return toTitleCaseWords(subscriptionType!); - } -} - -function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined { - const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, ""); - if (!normalized) return undefined; - if ( - normalized === "apikey" || - normalized === "anthropicapikey" || - normalized === "anthropicauthtoken" - ) { - return "apiKey"; - } - return undefined; -} - -function formatClaudeSubscriptionAuthLabel(subscriptionType: string): string { - const subscriptionLabel = - claudeSubscriptionLabel(subscriptionType) ?? toTitleCaseWords(subscriptionType); - const normalized = subscriptionLabel.toLowerCase().replace(/[\s_-]+/g, ""); - - if (normalized.startsWith("claude") && normalized.endsWith("subscription")) { - return subscriptionLabel; - } - if (normalized.startsWith("claude")) { - return `${subscriptionLabel} Subscription`; - } - if (normalized.endsWith("subscription")) { - return `Claude ${subscriptionLabel}`; - } - return `Claude ${subscriptionLabel} Subscription`; -} - -function claudeAuthMetadata(input: { - readonly subscriptionType: string | undefined; - readonly authMethod: string | undefined; -}): { readonly type: string; readonly label: string } | undefined { - if (normalizeClaudeAuthMethod(input.authMethod) === "apiKey") { - return { - type: "apiKey", - label: "Claude API Key", - }; - } - - if (input.subscriptionType) { - return { - type: input.subscriptionType, - label: formatClaudeSubscriptionAuthLabel(input.subscriptionType), - }; - } - - return undefined; -} - -function apiProviderAuthMetadata( - apiProvider: string | undefined, -): { readonly type: string; readonly label: string } | undefined { - return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; -} - -// ── SDK capability probe ──────────────────────────────────────────── - -// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the -// Bedrock backend and runs the `awsAuthRefresh` credential hook before returning -// account info. The previous 8s budget expired mid-init, so the probe returned -// `undefined` and left the provider unverified and unselectable in the picker. -const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; - -/** - * Keep workspace-scoped command discovery intact while isolating the periodic - * health check from configured MCP servers. - */ -export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ - "user", - "project", - "local", -] as const satisfies ReadonlyArray; - -/** Build the exact SDK options used by the periodic Claude capability probe. */ -export function buildClaudeCapabilitiesProbeQueryOptions(input: { - readonly executablePath: string; - readonly abortController: AbortController; - readonly environment: NodeJS.ProcessEnv; - readonly cwd: string | undefined; -}): ClaudeQueryOptions { - return { - persistSession: false, - pathToClaudeCodeExecutable: input.executablePath, - abortController: input.abortController, - settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], - // The probe keeps filesystem setting sources for slash-command discovery, - // but must not run the user's hooks: it fires every few minutes, so - // SessionStart hooks would run on every health check. - settings: { disableAllHooks: true }, - allowedTools: [], - // Ignore MCP definitions from every filesystem setting source above. The - // SDK combines this empty explicit map with --strict-mcp-config. - mcpServers: {}, - strictMcpConfig: true, - env: { - ...input.environment, - // Connected claude.ai MCP servers are discovered outside filesystem - // config; disable them independently for this health check. - ENABLE_CLAUDEAI_MCP_SERVERS: "false", - // This is a noninteractive health check, so IDE discovery cannot add any - // useful capability data. Skipping it also avoids Claude spawning a - // Windows `tasklist | findstr` process tree on every periodic refresh. - FORCE_CODE_TERMINAL: undefined, - CLAUDE_CODE_AUTO_CONNECT_IDE: "0", - CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", - }, - ...(input.cwd ? { cwd: input.cwd } : {}), - stderr: () => {}, - }; -} - -function nonEmptyProbeString(value: string): string | undefined { - const candidate = value.trim(); - return candidate ? candidate : undefined; -} - -type ClaudeCapabilitiesProbe = { - readonly email: string | undefined; - readonly subscriptionType: string | undefined; - readonly tokenSource: string | undefined; - /** - * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth - * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) - * the subscription/token fields are absent and auth is external AWS creds. - */ - readonly apiProvider: string | undefined; - readonly slashCommands: ReadonlyArray; - /** - * Subscription windows from the SDK's `get_usage` control request, or - * `undefined` when the request itself failed. Absent windows on an - * otherwise successful response mean the account has none (API key). - */ - readonly usage?: Pick; -}; - -function parseClaudeInitializationCommands( - commands: ReadonlyArray | undefined, -): ReadonlyArray { - return dedupeSlashCommands( - (commands ?? []).flatMap((command) => { - const name = nonEmptyProbeString(command.name); - if (!name) { - return []; - } - - const description = nonEmptyProbeString(command.description); - const argumentHint = nonEmptyProbeString(command.argumentHint); - - return [ - { - name, - ...(description ? { description } : {}), - ...(argumentHint ? { input: { hint: argumentHint } } : {}), - } satisfies ServerProviderSlashCommand, - ]; - }), - ); -} - -function dedupeSlashCommands( - commands: ReadonlyArray, -): ReadonlyArray { - const commandsByName = new Map(); - - for (const command of commands) { - const name = nonEmptyProbeString(command.name); - if (!name) { - continue; - } - - const key = name.toLowerCase(); - const existing = commandsByName.get(key); - if (!existing) { - commandsByName.set(key, { - ...command, - name, - }); - continue; - } - - commandsByName.set(key, { - ...existing, - ...(existing.description - ? {} - : command.description - ? { description: command.description } - : {}), - ...(existing.input?.hint - ? {} - : command.input?.hint - ? { input: { hint: command.input.hint } } - : {}), - }); - } - - return [...commandsByName.values()]; -} - -function waitForAbortSignal(signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.resolve(); - } - return new Promise((resolve) => { - signal.addEventListener("abort", () => resolve(), { once: true }); - }); -} - -/** - * Probe account information by spawning a lightweight Claude Agent SDK - * session and reading the initialization result. - * - * We pass a never-yielding AsyncIterable as the prompt so that no user - * message is ever written to the subprocess stdin. This means the Claude - * Code subprocess completes its local initialization IPC (returning - * account info and slash commands) but never starts an API request to - * Anthropic. We read the init data and then abort the subprocess. - * - * This is used as a fallback when `claude auth status` does not include - * subscription type information. - */ -const probeClaudeCapabilities = ( - claudeSettings: ClaudeSettings, - environment?: NodeJS.ProcessEnv, - cwd?: string, - includeUsage = true, -) => { - const abort = new AbortController(); - return Effect.gen(function* () { - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); - const executablePath = yield* resolveClaudeSdkExecutablePath( - claudeSettings.binaryPath, - claudeEnvironment, - ); - return yield* Effect.tryPromise(async () => { - const q = claudeQuery({ - // Never yield — we only need initialization data, not a conversation. - // This prevents any prompt from reaching the Anthropic API. - // oxlint-disable-next-line require-yield - prompt: (async function* (): AsyncGenerator { - await waitForAbortSignal(abort.signal); - })(), - options: buildClaudeCapabilitiesProbeQueryOptions({ - executablePath, - abortController: abort, - environment: claudeEnvironment, - cwd, - }), - }); - const init = await q.initializationResult(); - return { q, init }; - }); - }).pipe( - Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), - Effect.flatMap(({ q, init }) => - Effect.gen(function* () { - // Usage has its own deadline so a slow optional request cannot discard initialization. - const usageResult = includeUsage - ? yield* Effect.tryPromise(() => - q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), - ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result) - : undefined; - const usage = - usageResult && Result.isSuccess(usageResult) - ? { - rate_limits_available: usageResult.success.rate_limits_available, - rate_limits: usageResult.success.rate_limits, - } - : undefined; - const account = init.account as - | { - readonly email?: string; - readonly subscriptionType?: string; - readonly tokenSource?: string; - readonly apiProvider?: string; - } - | undefined; - return { - email: account?.email, - subscriptionType: account?.subscriptionType, - tokenSource: account?.tokenSource, - apiProvider: account?.apiProvider, - slashCommands: parseClaudeInitializationCommands(init.commands), - ...(usage ? { usage } : {}), - } satisfies ClaudeCapabilitiesProbe; - }), - ), - Effect.ensuring( - Effect.sync(() => { - if (!abort.signal.aborted) abort.abort(); - }), - ), - Effect.result, - Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)), - ); -}; - -const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( - claudeSettings: ClaudeSettings, - args: ReadonlyArray, - environment?: NodeJS.ProcessEnv, -) { - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); - const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { - env: claudeEnvironment, - }); - const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { - env: claudeEnvironment, - shell: spawnCommand.shell, - }); - return yield* spawnAndCollect(claudeSettings.binaryPath, command); -}); - -/** Read commands from the same cwd Claude uses for a workspace session. */ -export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnapshot")(function* ( - claudeSettings: ClaudeSettings, - machineSnapshot: ServerProvider, - cwd: string, - environment?: NodeJS.ProcessEnv, -): Effect.fn.Return { - if (!claudeSettings.enabled) return machineSnapshot; - const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); - const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false); - return { - ...machineSnapshot, - skills, - slashCommands: dedupeSlashCommands([ - COMPACT_SLASH_COMMAND, - ...(capabilities?.slashCommands ?? []), - ]), - slashCommandsPending: !capabilities, - }; -}); - -export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( - claudeSettings: ClaudeSettings, - resolveCapabilities?: ( - claudeSettings: ClaudeSettings, - ) => Effect.Effect, - environment?: NodeJS.ProcessEnv, - cwd?: string, - modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, - /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ - scopedLimitNames?: Ref.Ref, - /** Banked resets for a subscription login, given the CLI version for the user agent. */ - resolveResetCredits?: (version: string) => Effect.Effect, -): Effect.fn.Return< - ServerProviderDraft, - never, - ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path -> { - const resolvedEnvironment = environment ?? process.env; - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const allModels = providerModelsFromSettings( - modelCatalog.models.map((entry) => entry.model), - claudeSettings.customModels, - DEFAULT_CLAUDE_MODEL_CAPABILITIES, - ); - - if (!claudeSettings.enabled) { - return buildServerProvider({ - presentation: CLAUDE_PRESENTATION, - enabled: false, - checkedAt, - models: allModels, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Claude is disabled in T3 Code settings.", - }, - }); - } - - const versionProbe = yield* runClaudeCommand( - claudeSettings, - ["--version"], - resolvedEnvironment, - ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); - - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; - yield* Effect.logWarning("Claude Agent CLI health check failed.", { - errorTag: error._tag, - }); - return buildServerProvider({ - presentation: CLAUDE_PRESENTATION, - enabled: claudeSettings.enabled, - checkedAt, - models: allModels, - probe: { - installed: !isCommandMissingCause(error), - version: null, - status: "error", - auth: { status: "unknown" }, - message: isCommandMissingCause(error) - ? "Claude Agent CLI (`claude`) was not found on PATH." - : "Failed to execute Claude Agent CLI health check.", - }, - }); - } - - if (Option.isNone(versionProbe.success)) { - return buildServerProvider({ - presentation: CLAUDE_PRESENTATION, - enabled: claudeSettings.enabled, - checkedAt, - models: allModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: - "Claude Agent CLI is installed but failed to run. Timed out while running command.", - }, - }); - } - - const version = versionProbe.success.value; - const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); - if (version.code !== 0) { - yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { - exitCode: version.code, - stdoutLength: version.stdout.length, - stderrLength: version.stderr.length, - }); - return buildServerProvider({ - presentation: CLAUDE_PRESENTATION, - enabled: claudeSettings.enabled, - checkedAt, - models: allModels, - probe: { - installed: true, - version: parsedVersion, - status: "error", - auth: { status: "unknown" }, - message: "Claude Agent CLI is installed but failed to run.", - }, - }); - } - - const models = providerModelsFromSettings( - resolveClaudeModelsForVersion(modelCatalog, parsedVersion), - claudeSettings.customModels, - DEFAULT_CLAUDE_MODEL_CAPABILITIES, - ); - const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); - - const capabilities = resolveCapabilities - ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) - : undefined; - const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); - const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])]; - const dedupedSlashCommands = dedupeSlashCommands(slashCommands); - - if (!capabilities) { - return buildServerProvider({ - presentation: CLAUDE_PRESENTATION, - enabled: claudeSettings.enabled, - checkedAt, - models, - slashCommands: dedupedSlashCommands, - skills, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - auth: { status: "unknown" }, - message: "Could not verify Claude authentication status from initialization result.", - }, - }); - } - - const authMetadata = - claudeAuthMetadata({ - subscriptionType: capabilities.subscriptionType, - authMethod: capabilities.tokenSource, - }) ?? apiProviderAuthMetadata(capabilities.apiProvider); - const usageLimits = !capabilities.usage - ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) - : scopedLimitNames - ? yield* recordClaudeUsageResponse(scopedLimitNames, { - response: capabilities.usage, - checkedAt, - }) - : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits; - const resetCredits = - resolveResetCredits && - capabilities.subscriptionType && - !usageLimits.unavailable && - parsedVersion - ? yield* resolveResetCredits(parsedVersion) - : undefined; - return buildServerProvider({ - presentation: CLAUDE_PRESENTATION, - enabled: claudeSettings.enabled, - checkedAt, - models, - slashCommands: dedupedSlashCommands, - skills, - probe: { - installed: true, - version: parsedVersion, - status: "ready", - auth: { - status: "authenticated", - ...(capabilities.email ? { email: capabilities.email } : {}), - ...(authMetadata ? authMetadata : {}), - }, - ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), - usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits, - }, - }); -}); - -const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - -export const makePendingClaudeProvider = ( - claudeSettings: ClaudeSettings, - modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, -): Effect.Effect => - Effect.gen(function* () { - const checkedAt = yield* nowIso; - const models = providerModelsFromSettings( - modelCatalog.models.map((entry) => entry.model), - claudeSettings.customModels, - DEFAULT_CLAUDE_MODEL_CAPABILITIES, - ); - - if (!claudeSettings.enabled) { - return buildServerProvider({ - presentation: CLAUDE_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Claude is disabled in T3 Code settings.", - }, - }); - } - - return buildServerProvider({ - presentation: CLAUDE_PRESENTATION, - enabled: true, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Claude provider status has not been checked in this session yet.", - }, - }); - }); - -export { probeClaudeCapabilities }; diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts deleted file mode 100644 index 2c67b6f144dbb998716bcef4a30bde9de345e0a7..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { assert, it } from "@effect/vitest"; - -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; - -it("maps current Codex model capability fields", () => { - const capabilities = mapCodexModelCapabilities({ - additionalSpeedTiers: [], - defaultReasoningEffort: "super-high", - description: "Test model", - displayName: "GPT Test", - hidden: false, - id: "gpt-test", - isDefault: true, - model: "gpt-test", - defaultServiceTier: "flex", - serviceTiers: [ - { - id: "priority", - name: "Fast", - description: "Lower latency responses.", - }, - { - id: "flex", - name: "Flex", - description: "Lower-cost asynchronous routing.", - }, - ], - supportedReasoningEfforts: [ - { - description: "Maximum reasoning", - reasoningEffort: "super-high", - }, - ], - }); - - assert.deepStrictEqual(capabilities.optionDescriptors, [ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - options: [{ id: "super-high", label: "super-high", isDefault: true }], - currentValue: "super-high", - }, - { - id: "serviceTier", - label: "Service Tier", - type: "select", - options: [ - { id: "default", label: "Standard" }, - { - id: "priority", - label: "Fast", - description: "Lower latency responses.", - }, - { - id: "flex", - label: "Flex", - description: "Lower-cost asynchronous routing.", - isDefault: true, - }, - ], - currentValue: "flex", - }, - ]); -}); - -it("uses standard routing when the catalog has no default service tier", () => { - const capabilities = mapCodexModelCapabilities({ - additionalSpeedTiers: ["fast"], - defaultReasoningEffort: "medium", - defaultServiceTier: null, - description: "Test model", - displayName: "GPT Test", - hidden: false, - id: "gpt-test", - isDefault: true, - model: "gpt-test", - serviceTiers: [ - { - id: "priority", - name: "Fast", - description: "1.5x speed, increased usage", - }, - { - id: "ultrafast", - name: "Ultrafast", - description: "The fastest available responses for latency-sensitive work.", - }, - ], - supportedReasoningEfforts: [], - }); - - assert.deepStrictEqual(capabilities.optionDescriptors, [ - { - id: "serviceTier", - label: "Service Tier", - type: "select", - options: [ - { id: "default", label: "Standard", isDefault: true }, - { - id: "priority", - label: "Fast", - description: "1.5x speed, increased usage", - }, - { - id: "ultrafast", - label: "Ultrafast", - description: "Even faster, more expensive", - }, - ], - currentValue: "default", - }, - ]); -}); - -it("marks the most preferred available model as default", () => { - const models = applyPreferredCodexDefaultModel([ - { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, - { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, - ]); - - assert.deepStrictEqual( - models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), - [ - { slug: "gpt-5.6-terra", isDefault: true }, - { slug: "gpt-5.4", isDefault: undefined }, - ], - ); -}); - -it("prefers sol over terra when both are available", () => { - const models = applyPreferredCodexDefaultModel([ - { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, - { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, - ]); - - assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); -}); - -it("ranks qualified Codex models while preserving their wire ids", () => { - const models = applyPreferredCodexDefaultModel([ - { - slug: "openai.gpt-5.6-luna", - name: "Luna", - isCustom: false, - isDefault: true, - capabilities: null, - }, - { slug: "openai.gpt-5.6-sol", name: "Sol", isCustom: false, capabilities: null }, - ]); - assert.deepStrictEqual( - models.filter((model) => model.isDefault).map((model) => model.slug), - ["openai.gpt-5.6-sol"], - ); -}); - -it("keeps Codex's own default when no preferred model is available", () => { - const models = applyPreferredCodexDefaultModel([ - { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, - { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, - ]); - - assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); -}); - -it("ignores custom models that shadow a preferred slug", () => { - const models = applyPreferredCodexDefaultModel([ - { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null }, - { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, - ]); - - assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); -}); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts deleted file mode 100644 index 5140e87ecadacda4fc8746544473b3a3d858c6dd..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ /dev/null @@ -1,723 +0,0 @@ -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 Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Types from "effect/Types"; -import * as ChildProcess from "effect/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as CodexClient from "effect-codex-app-server/client"; -import * as CodexSchema from "effect-codex-app-server/schema"; -import * as CodexErrors from "effect-codex-app-server/errors"; - -import type { - CodexSettings, - CustomModelSetting, - ServerProvider, - ServerProviderState, - ModelCapabilities, - ProviderOptionDescriptor, - ServerProviderModel, - ServerProviderSkill, -} from "@t3tools/contracts"; -import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; - -import { - codexModelFamily, - createModelCapabilities, - formatCodexModelName, - readCustomModelEntries, -} from "@t3tools/shared/model"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; -import { - AUTH_PROBE_TIMEOUT_MS, - buildServerProvider, - COMPACT_SLASH_COMMAND, - type ServerProviderDraft, -} from "../providerSnapshot.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; -import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; -import { - codexRateLimitsFailureMessage, - codexRateLimitsToLimits, - type CodexRateLimitSnapshot, - type CodexResetCreditsSummary, -} from "./codexUsageLimits.ts"; -import packageJson from "../../../package.json" with { type: "json" }; -const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); -const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; - -type CodexRateLimitsProbe = - | { - readonly snapshot: CodexRateLimitSnapshot; - readonly rateLimitsByLimitId?: - | Readonly> - | null - | undefined; - readonly resetCredits: CodexResetCreditsSummary | null | undefined; - } - | { readonly failure: string }; - -const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; - -const CODEX_PRESENTATION = { - displayName: "Codex", - showInteractionModeToggle: true, - reportsContextWindow: true, -} as const; - -export interface CodexAppServerProviderSnapshot { - readonly account: CodexSchema.V2GetAccountResponse; - readonly rateLimits?: CodexRateLimitsProbe; - readonly version: string | undefined; - readonly models: ReadonlyArray; - readonly skills: ReadonlyArray; -} - -const REASONING_EFFORT_LABELS: Readonly> = { - none: "None", - minimal: "Minimal", - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - max: "Max", - ultra: "Ultra", -}; - -const DEFAULT_SERVICE_TIER_ID = "default"; - -/** Shorter copy for tiers whose catalog description wraps in the traits menu. */ -const SERVICE_TIER_DESCRIPTIONS: Readonly> = { - ultrafast: "Even faster, more expensive", -}; - -function reasoningEffortLabel(reasoningEffort: string): string { - return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; -} - -function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) { - if (!account) return undefined; - if (account.type === "apiKey") return "OpenAI API Key"; - if (account.type === "amazonBedrock") return "Amazon Bedrock"; - if (account.type !== "chatgpt") return undefined; - return codexPlanLabel(account.planType); -} - -/** Shared with usage-limit sources, which report the same `planType` slugs. */ -export function codexPlanLabel(planType: string | null | undefined): string | undefined { - switch (planType) { - case "free": - return "ChatGPT Free Subscription"; - case "go": - return "ChatGPT Go Subscription"; - case "plus": - return "ChatGPT Plus Subscription"; - case "pro": - return "ChatGPT Pro 20x Subscription"; - case "prolite": - return "ChatGPT Pro 5x Subscription"; - case "promax": - return "ChatGPT Pro Max Subscription"; - case "team": - return "ChatGPT Team Subscription"; - case "self_serve_business_prolite": - case "self_serve_business_usage_based": - case "business": - return "ChatGPT Business Subscription"; - case "ent26": - case "enterprise_cbp_automation": - case "enterprise_cbp_usage_based": - case "enterprise": - return "ChatGPT Enterprise Subscription"; - case "edu": - case "edu_plus": - case "edu_pro": - return "ChatGPT Edu Subscription"; - case "unknown": - return "ChatGPT Subscription"; - default: - return undefined; - } -} - -function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) { - if (!account || account.type !== "chatgpt") return undefined; - return account.email; -} - -export function mapCodexModelCapabilities( - model: CodexSchema.V2ModelListResponse__Model, -): ModelCapabilities { - const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => - reasoningEffort === - (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort) - ? { - id: reasoningEffort, - label: reasoningEffortLabel(reasoningEffort), - isDefault: true, - } - : { - id: reasoningEffort, - label: reasoningEffortLabel(reasoningEffort), - }, - ); - const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; - const serviceTiers = - model.serviceTiers && model.serviceTiers.length > 0 - ? model.serviceTiers - : (model.additionalSpeedTiers ?? []).map((id) => ({ - id, - name: id === "fast" ? "Fast" : id, - description: "", - })); - const catalogDefaultServiceTier = serviceTiers.some( - (tier) => tier.id === model.defaultServiceTier, - ) - ? model.defaultServiceTier - : null; - const defaultServiceTier = catalogDefaultServiceTier ?? DEFAULT_SERVICE_TIER_ID; - const optionDescriptors: ProviderOptionDescriptor[] = []; - - if (reasoningOptions.length > 0) { - optionDescriptors.push({ - id: "reasoningEffort", - label: "Reasoning", - type: "select", - options: reasoningOptions, - ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), - }); - } - if (serviceTiers.length > 0) { - optionDescriptors.push({ - id: "serviceTier", - label: "Service Tier", - type: "select", - options: [ - { - id: DEFAULT_SERVICE_TIER_ID, - label: "Standard", - ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), - }, - ...serviceTiers.map((tier) => { - const description = SERVICE_TIER_DESCRIPTIONS[tier.id] ?? tier.description; - return { - id: tier.id, - label: tier.name, - ...(description ? { description } : {}), - ...(defaultServiceTier === tier.id ? { isDefault: true } : {}), - }; - }), - ], - currentValue: defaultServiceTier, - }); - } - - return createModelCapabilities({ - optionDescriptors, - }); -} - -function parseCodexModelListResponse( - response: CodexSchema.V2ModelListResponse, -): ReadonlyArray { - return response.data.map((model) => ({ - slug: model.model, - name: formatCodexModelName(model.displayName), - isCustom: false, - ...(model.isDefault ? { isDefault: true } : {}), - capabilities: mapCodexModelCapabilities(model), - })); -} - -/** - * Prefer our own default-model ranking when one of the preferred slugs is in - * the live catalog; otherwise keep whatever Codex itself flagged as default. - */ -export function applyPreferredCodexDefaultModel( - models: ReadonlyArray, -): ReadonlyArray { - const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) => - models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug), - )[0]?.slug; - if (!preferredSlug) { - return models; - } - return models.map((model) => { - if (model.slug === preferredSlug) { - return model.isDefault ? model : { ...model, isDefault: true }; - } - if (!model.isDefault) { - return model; - } - const { isDefault: _isDefault, ...rest } = model; - return rest; - }); -} - -/** - * Codex has no static default capability set, so a bare custom slug borrows - * the first built-in's descriptors; an entry with its own capabilities keeps - * them. - */ -function appendCustomCodexModels( - models: ReadonlyArray, - customModels: ReadonlyArray, -): ReadonlyArray { - if (customModels.length === 0) { - return models; - } - - const seen = new Set(models.map((model) => model.slug)); - const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; - const customEntries: ServerProviderModel[] = []; - for (const entry of readCustomModelEntries(customModels)) { - if (seen.has(entry.slug)) { - continue; - } - seen.add(entry.slug); - customEntries.push({ - slug: entry.slug, - name: entry.name, - isCustom: true, - capabilities: entry.capabilities ?? fallbackCapabilities, - }); - } - return customEntries.length === 0 ? models : [...models, ...customEntries]; -} - -function parseCodexSkillsListResponse( - response: CodexSchema.V2SkillsListResponse, - cwd: string, -): ReadonlyArray { - const matchingEntry = response.data.find((entry) => entry.cwd === cwd); - const skills = matchingEntry - ? matchingEntry.skills - : response.data.flatMap((entry) => entry.skills); - - return skills.map((skill) => { - const shortDescription = - skill.shortDescription ?? skill.interface?.shortDescription ?? undefined; - - const parsedSkill: Types.Mutable = { - name: skill.name, - path: skill.path, - enabled: skill.enabled, - }; - - if (skill.description) { - parsedSkill.description = skill.description; - } - if (skill.scope) { - parsedSkill.scope = skill.scope; - } - if (skill.interface?.displayName) { - parsedSkill.displayName = skill.interface.displayName; - } - if (shortDescription) { - parsedSkill.shortDescription = shortDescription; - } - - return parsedSkill; - }); -} - -const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( - client: CodexClient.CodexAppServerClient["Service"], -) { - const models: ServerProviderModel[] = []; - let cursor: string | null | undefined = undefined; - - do { - const response: CodexSchema.V2ModelListResponse = yield* client.request( - "model/list", - cursor ? { cursor } : {}, - ); - models.push(...parseCodexModelListResponse(response)); - cursor = response.nextCursor; - } while (cursor); - - return models; -}); - -export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { - return { - clientInfo: { - name: "T3 Code", - title: "T3 Code", - version: packageJson.version, - }, - capabilities: { - experimentalApi: true, - }, - }; -} - -/** - * Spawns a short-lived `codex app-server`, runs the initialize handshake, and - * hands the caller a connected client. Scoped: the process is killed when - * the caller's scope closes. Shared by the status probe, the skills probe, - * and account-level requests such as reset-credit redemption. - */ -export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: { - readonly binaryPath: string; - readonly homePath?: string | undefined; - readonly launchArgs?: string | undefined; - readonly cwd: string; - readonly environment?: NodeJS.ProcessEnv | undefined; -}) { - // `~` is not shell-expanded when env vars are set via `child_process.spawn`, - // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip - // "CODEX_HOME points to '~/.codex_work', but that path does not exist". - // Expand here for parity with `CodexTextGeneration`. - const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const environment = { - ...input.environment, - ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), - }; - const spawnCommand = yield* resolveSpawnCommand( - input.binaryPath, - codexAppServerArgs(input.launchArgs), - { env: environment, extendEnv: true }, - ); - const child = yield* spawner - .spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: input.cwd, - env: environment, - extendEnv: true, - forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, - shell: spawnCommand.shell, - }), - ) - .pipe( - Effect.mapError( - (cause) => - new CodexErrors.CodexAppServerSpawnError({ - command: `${input.binaryPath} app-server`, - cause, - }), - ), - ); - const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); - const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.provide(clientContext), - ); - const initialize = yield* client.request("initialize", buildCodexInitializeParams()); - yield* client.notify("initialized", undefined); - return { client, initialize }; -}); - -const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly launchArgs?: string; - readonly cwd: string; - readonly customModels?: ReadonlyArray; - readonly environment?: NodeJS.ProcessEnv; - readonly skipNativeUsage?: boolean; -}) { - const { client, initialize } = yield* withCodexAppServerClient(input); - - // Extract the version string after the first '/' in userAgent, up to the next space or the end - const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); - const version = versionMatch ? versionMatch[1] : undefined; - - const accountResponse = yield* client.request("account/read", {}); - if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { - return { - account: accountResponse, - version, - models: appendCustomCodexModels([], input.customModels ?? []), - skills: [], - } satisfies CodexAppServerProviderSnapshot; - } - - const [skillsResponse, models, rateLimits] = yield* Effect.all( - [ - client.request("skills/list", { - cwds: [input.cwd], - }), - requestAllCodexModels(client), - // Usage is an enrichment: a failure or a slow answer degrades to "no - // usage this probe" rather than costing the account and models. - input.skipNativeUsage - ? Effect.succeed(undefined) - : client.request("account/rateLimits/read", null).pipe( - Effect.map((response): CodexRateLimitsProbe => ({ - snapshot: response.rateLimits, - rateLimitsByLimitId: response.rateLimitsByLimitId, - resetCredits: response.rateLimitResetCredits, - })), - Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), - Effect.map( - Option.getOrElse((): CodexRateLimitsProbe => ({ - failure: "Codex did not answer the usage request.", - })), - ), - Effect.catch((error) => - Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( - Effect.as({ failure: codexRateLimitsFailureMessage(error) }), - ), - ), - ), - ], - { concurrency: "unbounded" }, - ); - - return { - account: accountResponse, - ...(rateLimits ? { rateLimits } : {}), - version, - models: applyPreferredCodexDefaultModel( - appendCustomCodexModels(models, input.customModels ?? []), - ), - skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), - } satisfies CodexAppServerProviderSnapshot; -}); - -export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly launchArgs?: string; - readonly cwd: string; - readonly environment?: NodeJS.ProcessEnv; -}) { - const { client } = yield* withCodexAppServerClient(input); - const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); - return parseCodexSkillsListResponse(skillsResponse, input.cwd); -}); - -const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => - appendCustomCodexModels([], codexSettings.customModels); - -const makePendingCodexProvider = ( - codexSettings: CodexSettings, -): Effect.Effect => - Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = emptyCodexModelsFromSettings(codexSettings); - - if (!codexSettings.enabled) { - return buildServerProvider({ - presentation: CODEX_PRESENTATION, - enabled: false, - checkedAt, - models, - skills: [], - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Codex is disabled in T3 Code settings.", - }, - }); - } - - return buildServerProvider({ - presentation: CODEX_PRESENTATION, - enabled: true, - checkedAt, - models, - skills: [], - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Codex provider status has not been checked in this session yet.", - }, - }); - }); - -function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { - readonly status: Exclude; - readonly auth: ServerProvider["auth"]; - readonly message?: string; -} { - const authLabel = codexAccountAuthLabel(account.account); - const authEmail = codexAccountEmail(account.account); - const auth = { - status: account.account ? ("authenticated" as const) : ("unknown" as const), - ...(account.account?.type ? { type: account.account?.type } : {}), - ...(authLabel ? { label: authLabel } : {}), - ...(authEmail ? { email: authEmail } : {}), - } satisfies ServerProvider["auth"]; - - if (account.account) { - return { status: "ready", auth }; - } - - if (account.requiresOpenaiAuth) { - return { - status: "error", - auth: { status: "unauthenticated" }, - message: "Codex CLI is not authenticated. Run `codex login` and try again.", - }; - } - - return { status: "ready", auth }; -} - -export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( - codexSettings: CodexSettings, - probe: (input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly launchArgs?: string; - readonly cwd: string; - readonly customModels: ReadonlyArray; - readonly environment?: NodeJS.ProcessEnv; - readonly skipNativeUsage?: boolean; - }) => Effect.Effect< - CodexAppServerProviderSnapshot, - CodexErrors.CodexAppServerError, - ChildProcessSpawner.ChildProcessSpawner | Scope.Scope - > = probeCodexAppServerProvider, - environment?: NodeJS.ProcessEnv, - managedAuth?: ServerProvider["auth"], -): Effect.fn.Return< - ServerProviderDraft, - ServerSettingsError, - ChildProcessSpawner.ChildProcessSpawner -> { - const resolvedEnvironment = environment ?? process.env; - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const emptyModels = emptyCodexModelsFromSettings(codexSettings); - - if (!codexSettings.enabled) { - return buildServerProvider({ - presentation: CODEX_PRESENTATION, - enabled: false, - checkedAt, - models: emptyModels, - skills: [], - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Codex is disabled in T3 Code settings.", - }, - }); - } - - const probeResult = yield* probe({ - binaryPath: codexSettings.binaryPath, - homePath: codexSettings.homePath, - launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), - cwd: process.cwd(), - customModels: codexSettings.customModels, - environment: resolvedEnvironment, - ...(managedAuth ? { skipNativeUsage: true } : {}), - }).pipe( - Effect.scoped, - Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), - Effect.result, - ); - - if (Result.isFailure(probeResult)) { - const error = probeResult.failure; - const installed = !isCodexAppServerSpawnError(error); - return buildServerProvider({ - presentation: CODEX_PRESENTATION, - enabled: codexSettings.enabled, - checkedAt, - models: emptyModels, - skills: [], - probe: { - installed, - version: null, - status: "error", - auth: { status: "unknown" }, - message: installed - ? `Codex app-server provider probe failed: ${error.message}.` - : `Could not start Codex CLI (\`${codexSettings.binaryPath}\`). Check Settings → Providers → Codex → Binary path on the server.` + - (codexSettings.binaryPath === "codex" - ? " Installing ChatGPT or Codex desktop may not add codex to PATH." - : " Make sure the configured executable exists and can be run."), - }, - }); - } - - if (Option.isNone(probeResult.success)) { - return buildServerProvider({ - presentation: CODEX_PRESENTATION, - enabled: codexSettings.enabled, - checkedAt, - models: emptyModels, - skills: [], - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: "Timed out while checking Codex app-server provider status.", - }, - }); - } - - const snapshot = probeResult.success.value; - const accountStatus = managedAuth - ? { status: "ready" as const, auth: managedAuth, message: undefined } - : accountProbeStatus(snapshot.account); - const usageLimits = - snapshot.account.account?.type === "apiKey" - ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) - : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits - ? makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}), - }) - : codexRateLimitsToLimits({ - snapshot: snapshot.rateLimits.snapshot, - rateLimitsByLimitId: snapshot.rateLimits.rateLimitsByLimitId, - resetCredits: snapshot.rateLimits.resetCredits, - checkedAt, - }); - - return buildServerProvider({ - presentation: CODEX_PRESENTATION, - enabled: codexSettings.enabled, - checkedAt, - models: snapshot.models, - skills: snapshot.skills, - slashCommands: [ - COMPACT_SLASH_COMMAND, - { - name: "goal", - description: "Set a goal Codex keeps working toward until it is done", - input: { hint: "Objective, or pause, resume, clear" }, - }, - { - name: "feedback", - description: "Send this thread and Codex logs to OpenAI", - input: { hint: "Describe the issue (optional)" }, - }, - ], - probe: { - installed: true, - version: snapshot.version ?? null, - status: accountStatus.status, - auth: accountStatus.auth, - ...(accountStatus.message ? { message: accountStatus.message } : {}), - ...(managedAuth ? {} : { usageLimits }), - }, - }); -}); - -// NOTE: the singleton `CodexProviderLive` Layer has been removed as part of -// the per-instance-driver refactor. `CodexDriver.create()` builds a managed -// snapshot per instance (each with its own `CodexSettings`) and hands the -// resulting `ServerProviderShape` back as `ProviderInstance.snapshot`. -// -// The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are -// re-exported for use by `CodexDriver`. -export { makePendingCodexProvider }; diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts deleted file mode 100644 index 7fe35db9ea324bf85c965361aa260bd8d88012b7..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import type { SDKModel } from "@cursor/sdk"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import type { CursorSettings } from "@t3tools/contracts"; -import { CursorSettings as CursorSettingsSchema } from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; - -import { - buildCursorCapabilitiesFromSdkModel, - buildCursorDiscoveredModelsFromSdk, - buildCursorProviderSnapshot, - buildInitialCursorProviderSnapshot, - checkCursorProviderStatus, -} from "./CursorProvider.ts"; -import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; - -const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema); - -function selectDescriptor( - id: string, - label: string, - options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, -) { - return { - id, - label, - type: "select" as const, - options: [...options], - ...(options.find((option) => option.isDefault)?.id - ? { currentValue: options.find((option) => option.isDefault)?.id } - : {}), - }; -} - -function booleanDescriptor(id: string, label: string, currentValue?: boolean) { - return { - id, - label, - type: "boolean" as const, - ...(typeof currentValue === "boolean" ? { currentValue } : {}), - }; -} - -const baseCursorSettings: CursorSettings = decodeCursorSettings({ - enabled: true, - customModels: [], -}); - -const sdkParameterizedModel = { - id: "claude-opus-4-8", - displayName: "Opus 4.8", - parameters: [ - { - id: "thinking", - displayName: "Thinking", - values: [{ value: "false" }, { value: "true" }], - }, - { - id: "context", - displayName: "Context", - values: [ - { value: "300k", displayName: "300K" }, - { value: "1m", displayName: "1M" }, - ], - }, - { - id: "effort", - displayName: "Effort", - values: [ - { value: "low", displayName: "Low" }, - { value: "high", displayName: "High" }, - ], - }, - { - id: "fast", - displayName: "Fast", - values: [{ value: "false" }, { value: "true", displayName: "Fast" }], - }, - ], - variants: [ - { - displayName: "Opus 4.8", - isDefault: true, - params: [ - { id: "thinking", value: "true" }, - { id: "context", value: "1m" }, - { id: "effort", value: "high" }, - { id: "fast", value: "false" }, - ], - }, - ], -} satisfies SDKModel; - -describe("buildInitialCursorProviderSnapshot", () => { - it.effect("uses SDK-specific pending status copy", () => - Effect.gen(function* () { - const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings); - - expect(provider).toMatchObject({ - status: "warning", - message: "Checking Cursor SDK availability...", - }); - }), - ); -}); - -describe("buildCursorProviderSnapshot", () => { - it("downgrades ready status to warning when SDK model discovery returns no models", () => { - expect( - buildCursorProviderSnapshot({ - checkedAt: "2026-01-01T00:00:00.000Z", - cursorSettings: baseCursorSettings, - parsed: { - version: null, - status: "ready", - auth: { status: "authenticated", type: "api-key", label: "Cursor API key" }, - }, - discoveryWarning: "Cursor SDK model discovery returned no built-in models.", - }), - ).toMatchObject({ - status: "warning", - message: "Cursor SDK model discovery returned no built-in models.", - models: [], - supportsConversationRollback: false, - }); - }); -}); - -describe("Cursor SDK model discovery", () => { - it("maps native SDK parameter ids and default variant values to model capabilities", () => { - expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual( - createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("effort", "Effort", [ - { id: "low", label: "Low" }, - { id: "high", label: "High", isDefault: true }, - ]), - selectDescriptor("contextWindow", "Context", [ - { id: "300k", label: "300K" }, - { id: "1m", label: "1M", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast", false), - booleanDescriptor("thinking", "Thinking", true), - ], - }), - ); - }); - - it("filters invalid and duplicate SDK model entries", () => { - expect( - buildCursorDiscoveredModelsFromSdk([ - sdkParameterizedModel, - { ...sdkParameterizedModel, displayName: "Duplicate" }, - { id: "", displayName: "Invalid" }, - ]), - ).toEqual([ - { - slug: "claude-opus-4-8", - name: "Opus 4.8", - isCustom: false, - capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel), - }, - ]); - }); -}); - -describe("checkCursorProviderStatus", () => { - it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () => - Effect.gen(function* () { - const provider = yield* checkCursorProviderStatus( - { - ...baseCursorSettings, - customModels: ["internal/cursor-model"], - }, - { CURSOR_API_KEY: "test-cursor-key" }, - ).pipe( - Effect.provide( - CursorSdkCatalog.layerTest((apiKey) => { - expect(apiKey).toBe("test-cursor-key"); - return Effect.succeed({ - user: { - apiKeyName: "test-key", - userEmail: "cursor@example.com", - createdAt: "2026-01-01T00:00:00.000Z", - }, - models: [sdkParameterizedModel], - }); - }), - ), - ); - - expect(provider).toMatchObject({ - status: "ready", - auth: { - status: "authenticated", - type: "api-key", - label: "Cursor API key (test-key)", - email: "cursor@example.com", - }, - models: [ - { slug: "claude-opus-4-8", isCustom: false }, - { slug: "internal/cursor-model", isCustom: true }, - ], - }); - }), - ); - - it.effect("surfaces SDK authentication failures", () => - Effect.gen(function* () { - const provider = yield* checkCursorProviderStatus(baseCursorSettings, { - CURSOR_API_KEY: "invalid-test-key", - }).pipe( - Effect.provide( - CursorSdkCatalog.layerTest(() => - Effect.fail( - new CursorSdkCatalog.CursorSdkCatalogError({ - authenticationFailure: true, - cause: new Error("unauthorized"), - }), - ), - ), - ), - ); - - expect(provider).toMatchObject({ - status: "error", - auth: { status: "unauthenticated" }, - message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.", - }); - }), - ); - - it.effect("requires a Cursor API key without probing any external Cursor binary", () => - Effect.gen(function* () { - const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe( - Effect.provide( - CursorSdkCatalog.layerTest(() => - Effect.die("SDK catalog must not be used without CURSOR_API_KEY"), - ), - ), - ); - - expect(provider).toMatchObject({ - installed: true, - status: "error", - auth: { status: "unauthenticated" }, - message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", - }); - }), - ); -}); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts deleted file mode 100644 index 95ee80be056a937eeb0e44141b0456c2fe511beb..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ /dev/null @@ -1,350 +0,0 @@ -import type { SDKModel, SDKUser } from "@cursor/sdk"; -import type { - CursorSettings, - ModelCapabilities, - ProviderOptionDescriptor, - ServerProviderAuth, - ServerProviderModel, - ServerProviderState, -} from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; - -import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "../cursorSdkModel.ts"; -import { - buildBooleanOptionDescriptor, - buildSelectOptionDescriptor, - buildServerProvider, - providerModelsFromSettings, - type ServerProviderDraft, -} from "../providerSnapshot.ts"; -import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; - -const CURSOR_PRESENTATION = { - displayName: "Cursor", - supportsConversationRollback: false, - showInteractionModeToggle: true, -} as const; -const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); - -const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000; - -export function buildInitialCursorProviderSnapshot( - cursorSettings: CursorSettings, -): Effect.Effect { - return Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = getCursorFallbackModels(cursorSettings); - - if (!cursorSettings.enabled) { - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Cursor is disabled in T3 Code settings.", - }, - }); - } - - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: true, - checkedAt, - models, - probe: { - installed: true, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Checking Cursor SDK availability...", - }, - }); - }); -} - -function getCursorFallbackModels( - cursorSettings: Pick, -): ReadonlyArray { - return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); -} - -function toTitleCaseWords(value: string): string { - const parts: Array = []; - for (const part of value.split(/[\s_-]+/g)) { - if (part.length > 0) { - parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()); - } - } - return parts.join(" "); -} - -function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined { - return model.variants - ?.find((variant) => variant.isDefault) - ?.params.find((parameter) => parameter.id === parameterId)?.value; -} - -export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities { - const seen = new Set(); - const optionDescriptors: Array = []; - const parameters = (model.parameters ?? []) - .map((parameter, index) => ({ parameter, index })) - .toSorted( - (left, right) => - cursorSdkParameterPriority(left.parameter.id) - - cursorSdkParameterPriority(right.parameter.id) || left.index - right.index, - ); - for (const { parameter } of parameters) { - const nativeId = parameter.id.trim(); - const id = cursorSdkProviderOptionId(nativeId); - if (!nativeId || !id || seen.has(id)) { - continue; - } - seen.add(id); - - const values = parameter.values.flatMap((entry) => { - const value = entry.value.trim(); - if (!value) { - return []; - } - return [ - { - value, - label: entry.displayName?.trim() || value, - }, - ]; - }); - if (values.length === 0) { - continue; - } - - const label = parameter.displayName?.trim() || toTitleCaseWords(id); - const defaultValue = cursorSdkDefaultParameterValue(model, nativeId); - const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase())); - if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) { - if (defaultValue === "true" || defaultValue === "false") { - optionDescriptors.push( - buildBooleanOptionDescriptor({ - id, - label, - currentValue: defaultValue === "true", - }), - ); - } else { - optionDescriptors.push(buildBooleanOptionDescriptor({ id, label })); - } - continue; - } - - optionDescriptors.push( - buildSelectOptionDescriptor({ - id, - label, - options: values.map((entry) => ({ - ...entry, - ...(entry.value === defaultValue ? { isDefault: true } : {}), - })), - }), - ); - } - - return createModelCapabilities({ optionDescriptors }); -} - -export function buildCursorDiscoveredModelsFromSdk( - models: ReadonlyArray, -): ReadonlyArray { - const seen = new Set(); - return models.flatMap((model) => { - const slug = model.id.trim(); - const name = model.displayName.trim(); - if (!slug || !name || seen.has(slug)) { - return []; - } - seen.add(slug); - return [ - { - slug, - name, - isCustom: false, - capabilities: buildCursorCapabilitiesFromSdkModel(model), - } satisfies ServerProviderModel, - ]; - }); -} - -function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth { - const email = user.userEmail?.trim(); - const apiKeyName = user.apiKeyName.trim(); - return { - status: "authenticated", - type, - label: - type === "browser" - ? "Cursor account" - : apiKeyName - ? `Cursor API key (${apiKeyName})` - : "Cursor API key", - ...(email ? { email } : {}), - }; -} - -interface CursorProviderProbeResult { - readonly version: string | null; - readonly status: Exclude; - readonly auth: ServerProviderAuth; - readonly message?: string; -} - -function joinProviderMessages(...messages: ReadonlyArray): string | undefined { - const parts: Array = []; - for (const message of messages) { - const trimmed = message?.trim(); - if (trimmed) { - parts.push(trimmed); - } - } - return parts.length > 0 ? parts.join(" ") : undefined; -} - -export function buildCursorProviderSnapshot(input: { - readonly checkedAt: string; - readonly cursorSettings: CursorSettings; - readonly parsed: CursorProviderProbeResult; - readonly discoveredModels?: ReadonlyArray; - readonly discoveryWarning?: string; -}): ServerProviderDraft { - const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: input.cursorSettings.enabled, - checkedAt: input.checkedAt, - models: providerModelsFromSettings( - input.discoveredModels ?? [], - input.cursorSettings.customModels, - EMPTY_CAPABILITIES, - ), - probe: { - installed: true, - version: input.parsed.version, - status: - input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, - auth: input.parsed.auth, - ...(message ? { message } : {}), - }, - }); -} - -export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( - cursorSettings: CursorSettings, - environment?: NodeJS.ProcessEnv, - authenticationType: "api-key" | "browser" = "api-key", -): Effect.fn.Return { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const fallbackModels = getCursorFallbackModels(cursorSettings); - - if (!cursorSettings.enabled) { - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: false, - checkedAt, - models: fallbackModels, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Cursor is disabled in T3 Code settings.", - }, - }); - } - - const sdkApiKey = environment?.CURSOR_API_KEY?.trim(); - if (!sdkApiKey) { - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unauthenticated" }, - message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", - }, - }); - } - - const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog; - const catalogResult = yield* sdkCatalog - .read(sdkApiKey) - .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); - - if (Result.isFailure(catalogResult)) { - yield* Effect.logWarning("Cursor SDK catalog probe failed", { - cause: catalogResult.failure.cause, - }); - const authenticationFailure = catalogResult.failure.authenticationFailure; - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: authenticationFailure ? "unauthenticated" : "unknown" }, - message: authenticationFailure - ? authenticationType === "browser" - ? "Cursor sign-in expired or was rejected. Sign in again in provider settings." - : "Cursor SDK authentication failed. Check CURSOR_API_KEY." - : "Cursor SDK catalog request failed. Check server logs for details.", - }, - }); - } - - if (Option.isNone(catalogResult.success)) { - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`, - }, - }); - } - - const snapshot = catalogResult.success.value; - const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models); - return buildCursorProviderSnapshot({ - checkedAt, - cursorSettings, - parsed: { - version: null, - status: "ready", - auth: cursorSdkAuth(snapshot.user, authenticationType), - }, - discoveredModels, - ...(discoveredModels.length === 0 - ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." } - : {}), - }); -}); diff --git a/apps/server/src/provider/Layers/CursorSdkCatalog.test.ts b/apps/server/src/provider/Layers/CursorSdkCatalog.test.ts deleted file mode 100644 index 6cbd24f8b6be705b93f1cd4a48799b695587ded5..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/CursorSdkCatalog.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { SDKModel, SDKUser } from "@cursor/sdk"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; - -import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; - -const user = { - apiKeyName: "test-key", - userEmail: "cursor@example.com", - createdAt: "2026-01-01T00:00:00.000Z", -} satisfies SDKUser; - -const model = { - id: "claude-opus-4-8", - displayName: "Opus 4.8", - parameters: [], -} satisfies SDKModel; - -const catalogError = () => - new CursorSdkCatalog.CursorSdkCatalogError({ - authenticationFailure: false, - cause: new Error("catalog unavailable"), - }); - -describe("CursorSdkCatalog", () => { - it.effect("keeps the user probe fresh while caching models by API key", () => - Effect.gen(function* () { - let userCalls = 0; - let modelCalls = 0; - const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ - readUser: () => Effect.sync(() => ((userCalls += 1), user)), - readModels: () => Effect.sync(() => ((modelCalls += 1), [model])), - }); - - yield* catalog.read("first-key"); - yield* catalog.read("first-key"); - yield* catalog.read("second-key"); - - expect(userCalls).toBe(3); - expect(modelCalls).toBe(2); - }), - ); - - it.effect("does not cache failed or empty model discovery", () => - Effect.gen(function* () { - let modelCalls = 0; - const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ - readUser: () => Effect.succeed(user), - readModels: () => - Effect.suspend(() => { - modelCalls += 1; - if (modelCalls === 1) return Effect.fail(catalogError()); - if (modelCalls === 2) return Effect.succeed([]); - return Effect.succeed([model]); - }), - }); - - expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true); - expect((yield* catalog.read("test-key")).models).toEqual([]); - expect((yield* catalog.read("test-key")).models).toEqual([model]); - expect((yield* catalog.read("test-key")).models).toEqual([model]); - expect(modelCalls).toBe(3); - }), - ); -}); diff --git a/apps/server/src/provider/Layers/CursorSdkCatalog.ts b/apps/server/src/provider/Layers/CursorSdkCatalog.ts deleted file mode 100644 index fc0cf1b59baf59ba70d8ab6d18849f460917ff9f..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/CursorSdkCatalog.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { SDKModel, SDKUser } from "@cursor/sdk"; -import * as Cache from "effect/Cache"; -import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; - -import { AuthenticationError, Cursor, CursorSdkError } from "../cursorSdk.ts"; - -export interface CursorSdkCatalogSnapshot { - readonly user: SDKUser; - readonly models: ReadonlyArray; -} - -export class CursorSdkCatalogError extends Schema.TaggedError()( - "CursorSdkCatalogError", - { - authenticationFailure: Schema.Boolean, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return this.authenticationFailure - ? "Cursor SDK authentication failed." - : "Cursor SDK catalog request failed."; - } -} - -export interface CursorSdkCatalogShape { - readonly read: (apiKey: string) => Effect.Effect; -} - -export class CursorSdkCatalog extends Context.Service()( - "t3/provider/Layers/CursorSdkCatalog", -) {} - -function isAuthenticationFailure(cause: unknown): boolean { - return ( - cause instanceof AuthenticationError || - (cause instanceof CursorSdkError && cause.status === 401) - ); -} - -export interface CursorSdkCatalogProbes { - readonly readUser: (apiKey: string) => Effect.Effect; - readonly readModels: ( - apiKey: string, - ) => Effect.Effect, CursorSdkCatalogError>; -} - -const sdkRequest = (tryRequest: () => Promise) => - Effect.tryPromise({ - try: tryRequest, - catch: (cause) => - new CursorSdkCatalogError({ - authenticationFailure: isAuthenticationFailure(cause), - cause, - }), - }); - -const liveProbes: CursorSdkCatalogProbes = { - readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })), - readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })), -}; - -export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* ( - probes: CursorSdkCatalogProbes = liveProbes, -) { - const modelCache = yield* Cache.makeWith(probes.readModels, { - capacity: 32, - timeToLive: (exit) => - Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, - }); - return CursorSdkCatalog.of({ - read: (apiKey) => - Effect.all( - { - user: probes.readUser(apiKey), - models: Cache.get(modelCache, apiKey), - }, - { concurrency: "unbounded" }, - ), - }); -}); - -export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog()); - -export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer { - return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read })); -} diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts deleted file mode 100644 index e87372e03381cdc0ed2ec6f804a9ecdc8abc62f1..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ /dev/null @@ -1,856 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - -import { ThreadId } from "@t3tools/contracts"; -import { assert, describe, it } from "@effect/vitest"; -import * as Clock from "effect/Clock"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Logger from "effect/Logger"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; - -import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; -import { - makeEventNdjsonLogger, - makeEventNdjsonLogStore, - type PendingRecord, - writeBatchedMessages, -} from "./EventNdjsonLogger.ts"; - -const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); -const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); - -function ownedLogPath(basePath: string, segment: string): string { - const basename = NodePath.basename(basePath); - const extension = NodePath.extname(basename); - const stem = extension.length > 0 ? basename.slice(0, -extension.length) : basename; - return NodePath.join(NodePath.dirname(basePath), `${stem}.${segment}.log`); -} - -function parseLogLine(line: string) { - const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); - assert.notEqual(match, null); - if (!match) { - throw new Error(`invalid log line: ${line}`); - } - const observedAt = match[1]; - const stream = match[2]; - const payload = match[3]; - if (!observedAt || !stream || payload === undefined) { - throw new Error(`invalid log line: ${line}`); - } - return { - observedAt, - stream, - payload, - }; -} - -describe("EventNdjsonLogger", () => { - it.effect("summarizes circular events without exposing their contents in diagnostics", () => { - const messages: Array = []; - const logCapture = Logger.make(({ message }) => { - if (Array.isArray(message)) { - messages.push(...message); - } else { - messages.push(message); - } - }); - const secret = "secret-circular-event-value"; - - return Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "provider-native.ndjson"); - const circular: Record = { secret }; - circular.self = circular; - - try { - const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); - assert.exists(logger); - if (!logger) return; - yield* logger.write(circular, ThreadId.make("thread-1")); - yield* logger.close(); - - const serialized = encodeUnknownJson(messages); - assert.notInclude(serialized, secret); - const line = parseLogLine( - NodeFS.readFileSync(ownedLogPath(basePath, "thread-1"), "utf8").trim(), - ); - assert.equal(line.payload, '{"truncated":true}'); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); - }); - - it.effect("writes effect-style lines to thread-scoped files", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "provider-native.ndjson"); - - try { - const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } - - yield* logger.write( - { threadId: "provider-thread-1", id: "evt-1" }, - ThreadId.make("thread-1"), - ); - yield* logger.write( - { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" }, - ThreadId.make("thread-2"), - ); - yield* logger.close(); - - const threadOnePath = ownedLogPath(basePath, "thread-1"); - const threadTwoPath = ownedLogPath(basePath, "thread-2"); - assert.equal(NodeFS.existsSync(threadOnePath), true); - assert.equal(NodeFS.existsSync(threadTwoPath), true); - - const first = parseLogLine(NodeFS.readFileSync(threadOnePath, "utf8").trim()); - const second = parseLogLine(NodeFS.readFileSync(threadTwoPath, "utf8").trim()); - - assert.equal(Number.isNaN(Date.parse(first.observedAt)), false); - assert.equal(first.stream, "NTIVE"); - assert.equal(first.payload, '{"threadId":"provider-thread-1","id":"evt-1"}'); - - assert.equal(Number.isNaN(Date.parse(second.observedAt)), false); - assert.equal(second.stream, "NTIVE"); - assert.equal( - second.payload, - '{"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}', - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect( - "falls back to a global segment when orchestration thread id is missing or invalid", - () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); - - try { - const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } - - yield* logger.write({ id: "evt-no-thread" }, null); - yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); - yield* logger.close(); - - const globalPath = ownedLogPath(basePath, "_global"); - assert.equal(NodeFS.existsSync(globalPath), true); - const lines = NodeFS.readFileSync(globalPath, "utf8") - .trim() - .split("\n") - .map((line) => parseLogLine(line)); - assert.equal(lines.length, 2); - assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false); - assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false); - assert.equal(lines[0]?.stream, "ORCH"); - assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}'); - assert.equal(lines[1]?.stream, "ORCH"); - assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("shares one thread writer across native and canonical streams", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - const native = store.logger("native"); - const canonical = store.logger("canonical"); - const threadId = ThreadId.make("thread-shared"); - - yield* native.write({ id: "native-event" }, threadId); - yield* canonical.write({ type: "item.completed", id: "canonical-event" }, threadId); - yield* store.close(); - - const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared"), "utf8") - .trim() - .split("\n") - .map(parseLogLine); - - assert.deepEqual( - lines.map(({ stream, payload }) => ({ stream, payload })), - [ - { stream: "NTIVE", payload: '{"id":"native-event"}' }, - { - stream: "CANON", - payload: '{"type":"item.completed","id":"canonical-event"}', - }, - ], - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("keeps shared store views non-owning when one adapter closes", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - const native = store.logger("native"); - const canonical = store.logger("canonical"); - const threadId = ThreadId.make("thread-shared-close"); - - yield* native.write({ id: "before-close" }, threadId); - yield* native.close(); - yield* canonical.write({ type: "item.completed", id: "after-close" }, threadId); - yield* store.close(); - - const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared-close"), "utf8") - .trim() - .split("\n") - .map(parseLogLine); - - assert.deepEqual( - lines.map(({ stream, payload }) => ({ stream, payload })), - [ - { stream: "NTIVE", payload: '{"id":"before-close"}' }, - { - stream: "CANON", - payload: '{"type":"item.completed","id":"after-close"}', - }, - ], - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("flushes an active batch without a permanent polling loop", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const threadPath = ownedLogPath(basePath, "thread-batched"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); - yield* store - .logger("native") - .write({ id: "batched-event" }, ThreadId.make("thread-batched")); - - assert.equal(NodeFS.existsSync(threadPath), false); - yield* TestClock.adjust(1_000); - assert.equal(NodeFS.existsSync(threadPath), true); - yield* store.close(); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("does not strand a later batch after an interrupted write", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const threadPath = ownedLogPath(basePath, "thread-interrupted"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); - const logger = store.logger("native"); - const interruptedWrite = yield* logger - .write({ id: "possibly-interrupted" }, ThreadId.make("thread-interrupted")) - .pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* Fiber.interrupt(interruptedWrite); - yield* logger.write({ id: "accepted" }, ThreadId.make("thread-interrupted")); - - yield* TestClock.adjust(1_000); - - assert.equal(NodeFS.existsSync(threadPath), true); - assert.include(NodeFS.readFileSync(threadPath, "utf8"), '{"id":"accepted"}'); - yield* store.close(); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("drops transient provider events before serialization", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - const canonical = store.logger("canonical"); - const native = store.logger("native"); - const threadId = ThreadId.make("thread-filtered"); - const circularDelta: Record = { type: "content.delta" }; - circularDelta["self"] = circularDelta; - - yield* canonical.write(circularDelta, threadId); - yield* canonical.write({ type: "item.completed", id: "final" }, threadId); - yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); - yield* native.write( - { method: "item/agentMessage/delta", payload: circularDelta }, - threadId, - ); - yield* native.write( - { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, - threadId, - ); - yield* native.write( - { method: "thread/realtime/transcript/delta", payload: circularDelta }, - threadId, - ); - yield* native.write({ method: "turn/diff/updated", payload: circularDelta }, threadId); - yield* native.write( - { - event: { - direction: "incoming", - stage: "decoded", - payload: { - method: "turn/diff/updated", - get params() { - throw new Error("unused diff snapshots must not be traversed"); - }, - }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - method: "claude/stream_event/content_block_delta/text_delta", - payload: circularDelta, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - method: "session/update", - payload: { update: { sessionUpdate: "agent_message_chunk" } }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - direction: "incoming", - stage: "decoded", - payload: { method: "item/commandExecution/outputDelta", params: circularDelta }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - direction: "incoming", - stage: "decoded", - payload: { - type: "stream_event", - event: { type: "content_block_delta", delta: circularDelta }, - }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - direction: "incoming", - stage: "raw", - get payload() { - throw new Error("raw frames must not be inspected"); - }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - type: "message.part.updated", - payload: { properties: { part: { type: "text" } } }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - type: "message.part.updated", - payload: { - properties: { - part: { type: "tool", state: { status: "running", output: "progress" } }, - }, - }, - }, - }, - threadId, - ); - yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); - yield* store.close(); - - const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") - .trim() - .split("\n") - .map(parseLogLine); - - assert.deepEqual( - lines.map(({ stream, payload }) => ({ stream, payload })), - [ - { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, - { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, - ], - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("summarizes large histories without reading their items", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const turns = Array.from({ length: 10_000 }); - Object.defineProperty(turns, 0, { - get: () => { - throw new Error("history must not be serialized"); - }, - }); - - try { - const store = yield* makeEventNdjsonLogStore(basePath); - yield* store.logger("native").write( - { - provider: "codex", - event: { - direction: "incoming", - stage: "decoded", - payload: { id: 42, result: { thread: { id: "native-thread", turns } } }, - }, - }, - ThreadId.make("large-history"), - ); - yield* store.close(); - - const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-history"), "utf8"); - assert.isBelow(Buffer.byteLength(contents), 2_048); - const record = decodeUnknownJson(parseLogLine(contents.trim()).payload); - assert.nestedPropertyVal(record, "event.payload.id", 42); - assert.nestedPropertyVal(record, "event.payload.result.thread.id", "native-thread"); - assert.nestedPropertyVal(record, "event.payload.result.thread.turns.itemCount", 10_000); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("bounds oversized records while retaining failure details", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath); - const logger = store.logger("native"); - const threadId = ThreadId.make("large-error"); - const failure = { - method: "error", - params: { - threadId: "native-thread", - turnId: "native-turn", - error: { message: "The provider is unavailable.", code: "overloaded" }, - output: "x".repeat(128 * 1_024), - }, - }; - yield* logger.write(failure, threadId); - yield* logger.write({ id: "escaped", output: "\u0000".repeat(20_000) }, threadId); - yield* store.close(); - - const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-error"), "utf8"); - const records = contents - .trim() - .split("\n") - .map((line) => decodeUnknownJson(parseLogLine(line).payload)); - assert.isBelow(Buffer.byteLength(contents), 64 * 1_024); - assert.equal(records.length, 2); - assert.nestedPropertyVal(records[0], "params.threadId", "native-thread"); - assert.nestedPropertyVal(records[0], "params.turnId", "native-turn"); - assert.nestedPropertyVal( - records[0], - "params.error.message", - "The provider is unavailable.", - ); - assert.nestedPropertyVal(records[0], "params.error.code", "overloaded"); - assert.propertyVal(records[1], "id", "escaped"); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("bounds canonical diff snapshots before serializing their duplicate payloads", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const threadId = ThreadId.make("large-diff"); - const diff = "diff-payload".repeat(128 * 1_024); - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - yield* store.logger("canonical").write( - { - type: "turn.diff.updated", - threadId, - turnId: "native-turn", - raw: { method: "turn/diff/updated", payload: { diff } }, - payload: { unifiedDiff: diff }, - }, - threadId, - ); - yield* store.close(); - const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-diff"), "utf8"); - assert.isBelow(Buffer.byteLength(contents), 2_048); - const record = decodeUnknownJson(parseLogLine(contents.trim()).payload); - assert.propertyVal(record, "type", "turn.diff.updated"); - assert.propertyVal(record, "threadId", threadId); - assert.propertyVal(record, "turnId", "native-turn"); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("keeps OpenCode tool input, final output, and errors in native logs", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const threadId = ThreadId.make("thread-tool-lifecycle"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - const native = store.logger("native"); - const input = { command: "run-build" }; - const states = [ - { status: "pending", input }, - { status: "completed", input, output: "Build complete", time: { start: 1, end: 2 } }, - { status: "error", input, error: "Build failed", time: { start: 3, end: 4 } }, - { status: "unknown", input }, - null, - ]; - const events = states.map((state) => ({ - event: { - type: "message.part.updated", - payload: { properties: { part: { type: "tool", state } } }, - }, - })); - for (const event of events) { - yield* native.write(event, threadId); - } - yield* store.close(); - - const payloads = NodeFS.readFileSync( - ownedLogPath(basePath, "thread-tool-lifecycle"), - "utf8", - ) - .trim() - .split("\n") - .map((line) => parseLogLine(line).payload); - assert.deepEqual( - payloads, - events.map((event) => encodeUnknownJson(event)), - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("contains hostile event accessors inside guarded serialization", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - - try { - const logger = yield* makeEventNdjsonLogger(basePath, { - stream: "canonical", - batchWindowMs: 0, - }); - assert.exists(logger); - if (!logger) return; - const hostile = new Proxy( - { id: "hostile" }, - { - get(_target, property) { - if (property === "type") throw new Error("blocked"); - return undefined; - }, - }, - ); - - yield* logger.write(hostile, ThreadId.make("thread-hostile")); - yield* logger.close(); - - const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8"); - assert.notInclude(contents, "blocked"); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("serializes concurrent first writes for the same segment", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); - - try { - const logger = yield* makeEventNdjsonLogger(basePath, { - stream: "canonical", - batchWindowMs: 0, - }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } - - yield* Effect.all( - [ - logger.write({ id: "evt-concurrent-1" }, null), - logger.write({ id: "evt-concurrent-2" }, null), - ], - { concurrency: "unbounded" }, - ); - yield* logger.close(); - - const globalPath = ownedLogPath(basePath, "_global"); - assert.equal(NodeFS.existsSync(globalPath), true); - const lines = NodeFS.readFileSync(globalPath, "utf8") - .trim() - .split("\n") - .map((line) => parseLogLine(line)); - - assert.equal(lines.length, 2); - assert.deepEqual(lines.map((line) => line.payload).toSorted(), [ - '{"id":"evt-concurrent-1"}', - '{"id":"evt-concurrent-2"}', - ]); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("rotates per-thread files when max size is exceeded", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "provider-native.ndjson"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { - maxBytes: 120, - maxFiles: 2, - batchWindowMs: 0, - }); - const native = store.logger("native"); - const canonical = store.logger("canonical"); - - for (let index = 0; index < 10; index += 1) { - const logger = index % 2 === 0 ? native : canonical; - yield* logger.write( - { - type: "session.started", - threadId: "provider-thread-rotate", - id: `evt-${index}`, - payload: "x".repeat(40), - }, - ThreadId.make("thread-rotate"), - ); - } - yield* store.close(); - - const fileStem = NodePath.basename(ownedLogPath(basePath, "thread-rotate")); - const matchingFiles = NodeFS.readdirSync(tempDir) - .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) - .toSorted(); - - assert.equal( - matchingFiles.some((entry) => entry === `${fileStem}.1`), - true, - ); - assert.equal( - matchingFiles.some((entry) => entry === fileStem || entry === `${fileStem}.2`), - true, - ); - assert.equal( - matchingFiles.some((entry) => entry === `${fileStem}.3`), - false, - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("enforces aggregate age and byte retention on startup", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const expiredPath = ownedLogPath(basePath, "expired"); - const oldPath = ownedLogPath(basePath, "old"); - const newPath = ownedLogPath(basePath, "new"); - const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log"); - const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log"); - const ignoredPath = NodePath.join(tempDir, "ignored.txt"); - - try { - yield* TestClock.setTime(1_800_000_000_000); - const now = yield* Clock.currentTimeMillis; - for (const filePath of [expiredPath, oldPath, newPath, unrelatedLogPath, ignoredPath]) { - NodeFS.writeFileSync(filePath, "x".repeat(40)); - } - NodeFS.writeFileSync( - legacyLogPath, - "[2026-01-01T00:00:00.000Z] CANON: legacy provider event\n", - ); - NodeFS.utimesSync(expiredPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); - NodeFS.utimesSync(legacyLogPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); - NodeFS.utimesSync(oldPath, (now - 5_000) / 1_000, (now - 5_000) / 1_000); - NodeFS.utimesSync(newPath, now / 1_000, now / 1_000); - - const store = yield* makeEventNdjsonLogStore(basePath, { - maxAgeMs: 10_000, - maxTotalBytes: 60, - }); - yield* store.close(); - - assert.equal(NodeFS.existsSync(expiredPath), false); - assert.equal(NodeFS.existsSync(legacyLogPath), false); - assert.equal(NodeFS.existsSync(oldPath), false); - assert.equal(NodeFS.existsSync(newPath), true); - assert.equal(NodeFS.existsSync(unrelatedLogPath), true); - assert.equal(NodeFS.existsSync(ignoredPath), true); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("does not prune an active thread sink during an unrelated flush", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const activePath = ownedLogPath(basePath, "active"); - - try { - yield* TestClock.setTime(1_800_000_000_000); - const store = yield* makeEventNdjsonLogStore(basePath, { - batchWindowMs: 0, - maxAgeMs: 1, - retentionCheckIntervalMs: 1, - }); - const logger = store.logger("native"); - - yield* logger.write({ id: "active-before-retention" }, ThreadId.make("active")); - assert.equal(NodeFS.existsSync(activePath), true); - - yield* TestClock.adjust("2 millis"); - yield* logger.write({ id: "retention-trigger" }, ThreadId.make("other")); - - assert.equal(NodeFS.existsSync(activePath), true); - yield* store.close(); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it("attributes batches that were written before a later chunk fails", () => { - const records: ReadonlyArray = [ - { - stream: "native", - threadSegment: "thread", - line: "first", - bytes: 5, - }, - { - stream: "canonical", - threadSegment: "thread", - line: "second", - bytes: 6, - }, - ]; - const attributed: Array = []; - let writes = 0; - - assert.throws(() => - writeBatchedMessages( - { - write: () => { - writes += 1; - if (writes === 2) throw new Error("simulated disk exhaustion"); - }, - }, - records, - 5, - (written) => attributed.push(...written), - ), - ); - assert.deepEqual(attributed, [records[0]]); - }); - - it.effect("reports logical provider log writes to resource attribution", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "provider-native.ndjson"); - - try { - const attribution = yield* ResourceAttribution.make(); - const logger = yield* makeEventNdjsonLogger(basePath, { - stream: "native", - batchWindowMs: 0, - attribution, - }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } - - yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution")); - yield* logger.close(); - - const snapshot = yield* attribution.snapshot; - assert.equal(snapshot.entries.length, 1); - assert.equal(snapshot.entries[0]?.component, "provider-event-log"); - assert.equal(snapshot.entries[0]?.operation, "native.append"); - assert.equal(snapshot.entries[0]?.count, 1); - assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); -}); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts deleted file mode 100644 index 7068f3d6e9e9166731b274e1cf8cf5080ea36d29..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ /dev/null @@ -1,805 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -/** - * Best-effort provider event logging with one shared writer per thread. - * - * Native and canonical views share batching, rotation, and retention state so - * they cannot race while appending to the same thread-scoped file. - */ -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; - -import type { ThreadId } from "@t3tools/contracts"; -import { RotatingFileSink } from "@t3tools/shared/logging"; -import { errorTag } from "@t3tools/shared/observability"; -import * as Clock from "effect/Clock"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as SynchronizedRef from "effect/SynchronizedRef"; - -import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; -import type { ResourceAttribution } from "../../resourceTelemetry/ResourceAttribution.ts"; - -const MEBIBYTE = 1024 * 1024; -const DAY_MS = 24 * 60 * 60 * 1_000; -const DEFAULT_MAX_BYTES = 10 * MEBIBYTE; -const DEFAULT_MAX_FILES = 10; -const DEFAULT_BATCH_WINDOW_MS = 1_000; -const DEFAULT_MAX_TOTAL_BYTES = 512 * MEBIBYTE; -const DEFAULT_MAX_AGE_MS = 14 * DAY_MS; -const DEFAULT_RETENTION_CHECK_INTERVAL_MS = 5 * 60 * 1_000; -const DEFAULT_MAX_BUFFERED_BYTES = MEBIBYTE; -const DEFAULT_MAX_BUFFERED_RECORDS = 512; -const MAX_RECORD_CHARACTERS = 64 * 1024; -const MAX_RECORD_FIELDS = 1_024; -const MAX_RECORD_DEPTH = 16; -const GLOBAL_THREAD_SEGMENT = "_global"; -const LOG_SCOPE = "provider-observability"; -const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); - -const transientCanonicalEventTypes = new Set([ - "content.delta", - "hook.progress", - "item.updated", - "task.progress", - "thread.realtime.audio.delta", - "tool.progress", - "turn.proposed.delta", -]); -const transientNativeMethods = new Set([ - "item/agentMessage/delta", - "item/commandExecution/outputDelta", - "item/fileChange/outputDelta", - "item/plan/delta", - "item/reasoning/summaryTextDelta", - "item/reasoning/textDelta", - "thread/realtime/outputAudio/delta", - "thread/realtime/transcript/delta", - "turn/diff/updated", -]); -const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); - -export type EventNdjsonStream = "native" | "canonical" | "orchestration"; - -export interface EventNdjsonLogger { - readonly filePath: string; - readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; - readonly close: () => Effect.Effect; -} - -export interface EventNdjsonLogStore { - readonly filePath: string; - readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger; - readonly close: () => Effect.Effect; -} - -export interface EventNdjsonLogStoreOptions { - readonly maxBytes?: number; - readonly maxFiles?: number; - readonly batchWindowMs?: number; - readonly maxTotalBytes?: number; - readonly maxAgeMs?: number; - readonly retentionCheckIntervalMs?: number; - readonly maxBufferedBytes?: number; - readonly maxBufferedRecords?: number; - readonly attribution?: ResourceAttribution["Service"]; -} - -export interface EventNdjsonLoggerOptions extends EventNdjsonLogStoreOptions { - readonly stream: EventNdjsonStream; -} - -export class EventNdjsonLogConfigurationError extends Schema.TaggedError()( - "EventNdjsonLogConfigurationError", - { - filePath: Schema.String, - option: Schema.String, - value: Schema.Number, - minimum: Schema.Number, - }, -) { - override get message(): string { - return `Provider event log option '${this.option}' must be an integer >= ${this.minimum}; received ${this.value} for '${this.filePath}'`; - } -} - -export class EventNdjsonLogDirectoryError extends Schema.TaggedError()( - "EventNdjsonLogDirectoryError", - { - directory: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to create provider event log directory '${this.directory}'`; - } -} - -export type EventNdjsonLogStoreError = - | EventNdjsonLogConfigurationError - | EventNdjsonLogDirectoryError; - -interface ResolvedOptions { - readonly maxBytes: number; - readonly maxFiles: number; - readonly batchWindowMs: number; - readonly maxTotalBytes: number; - readonly maxAgeMs: number; - readonly retentionCheckIntervalMs: number; - readonly maxBufferedBytes: number; - readonly maxBufferedRecords: number; - readonly attribution: ResourceAttribution["Service"] | undefined; -} - -export interface PendingRecord { - readonly stream: EventNdjsonStream; - readonly threadSegment: string; - readonly line: string; - readonly bytes: number; -} - -interface StoreState { - readonly pending: Array; - readonly pendingBytes: number; - readonly sinks: ReadonlyMap; - readonly flushScheduled: boolean; - readonly closed: boolean; - readonly lastRetentionAt: number; -} - -interface AttributionSummary { - readonly stream: EventNdjsonStream; - readonly count: number; - readonly logicalWriteBytes: number; -} - -interface FileOperationFailure { - readonly filePath: string; - readonly cause: unknown; -} - -interface RetentionResult { - readonly failures: ReadonlyArray; -} - -interface DrainResult { - readonly attributions: ReadonlyArray; - readonly failures: ReadonlyArray; -} - -function logWarning(message: string, context: Record): Effect.Effect { - return Effect.logWarning(message, context).pipe(Effect.annotateLogs({ scope: LOG_SCOPE })); -} - -function resolveThreadSegment(raw: string | null | undefined): string { - const normalized = typeof raw === "string" ? toSafeThreadAttachmentSegment(raw) : null; - return normalized ?? GLOBAL_THREAD_SEGMENT; -} - -function resolveStreamLabel(stream: EventNdjsonStream): string { - return stream === "native" ? "NTIVE" : stream === "orchestration" ? "ORCH" : "CANON"; -} - -function providerLogPrefix(filePath: string): string { - const basename = NodePath.basename(filePath); - const extension = NodePath.extname(basename); - return `${extension.length > 0 ? basename.slice(0, -extension.length) : basename}.`; -} - -function providerLogPath(directory: string, prefix: string, threadSegment: string): string { - return NodePath.join(directory, `${prefix}${threadSegment}.log`); -} - -export function shouldPersistProviderEvent(stream: EventNdjsonStream, event: unknown): boolean { - if (stream === "orchestration" || typeof event !== "object" || event === null) { - return true; - } - try { - const type = Reflect.get(event, "type"); - if (typeof type === "string" && transientCanonicalEventTypes.has(type)) { - return false; - } - if (stream !== "native") return true; - - const nested = Reflect.get(event, "event"); - const envelope = typeof nested === "object" && nested !== null ? nested : event; - // Decoded frames carry the same information as raw frames without another - // copy of every token delta. Decode failures have their own diagnostic frame. - if (Reflect.get(envelope, "stage") === "raw") return false; - const decodedPayload = Reflect.get(envelope, "payload"); - const nativeEvent = - Reflect.get(envelope, "stage") === "decoded" && - typeof decodedPayload === "object" && - decodedPayload !== null - ? decodedPayload - : envelope; - const method = Reflect.get(nativeEvent, "method"); - if ( - typeof method === "string" && - (transientNativeMethods.has(method) || - method.startsWith("claude/stream_event/content_block_delta/")) - ) { - return false; - } - - const nativeType = Reflect.get(nativeEvent, "type"); - if (nativeType === "message.part.delta") return false; - if (nativeType === "stream_event") { - const streamEvent = Reflect.get(nativeEvent, "event"); - if ( - typeof streamEvent === "object" && - streamEvent !== null && - Reflect.get(streamEvent, "type") === "content_block_delta" - ) { - return false; - } - } - - const payload = Reflect.get(nativeEvent, "payload"); - if (typeof payload !== "object" || payload === null) return true; - - if (method === "session/update") { - const update = Reflect.get(payload, "update"); - if (typeof update !== "object" || update === null) return true; - const updateType = Reflect.get(update, "sessionUpdate"); - return typeof updateType !== "string" || !transientAcpUpdates.has(updateType); - } - - if (nativeType === "message.part.updated") { - const properties = Reflect.get(payload, "properties"); - if (typeof properties !== "object" || properties === null) return true; - const part = Reflect.get(properties, "part"); - if (typeof part !== "object" || part === null) return true; - const partType = Reflect.get(part, "type"); - if (partType === "text" || partType === "reasoning") return false; - if (partType === "tool") { - // Running snapshots repeat growing output. Pending and terminal states stay in the log. - const state = Reflect.get(part, "state"); - if (typeof state === "object" && state !== null) { - return Reflect.get(state, "status") !== "running"; - } - } - return true; - } - - return true; - } catch { - return true; - } -} - -const summaryFields = [ - "provider", - "protocol", - "kind", - "providerSessionId", - "direction", - "stage", - "type", - "subtype", - "method", - "id", - "threadId", - "turnId", - "requestId", - "session_id", - "status", - "is_error", - "api_error_status", - "terminal_reason", - "stop_reason", - "operation", - "code", - "willRetry", - "message", - "event", - "payload", - "params", - "result", - "thread", - "turn", - "error", - "turns", - "items", - "content", -] as const; - -function summarizeProviderEvent(event: unknown): unknown { - let remainingFields = 128; - let remainingCharacters = 8 * 1024; - const summarize = (value: unknown, depth: number): unknown => { - if (typeof value === "string") { - if (value.length > Math.min(1_024, remainingCharacters)) { - return { omittedCharacters: value.length }; - } - remainingCharacters -= value.length; - return value; - } - if (value === null || typeof value === "number" || typeof value === "boolean") return value; - if (typeof value !== "object") return undefined; - if (Array.isArray(value)) return { itemCount: value.length }; - const summary: Record = { truncated: true }; - if (depth >= 6) return summary; - for (const key of summaryFields) { - if (remainingFields <= 0) break; - const nested = Reflect.get(value, key); - if (nested === undefined) continue; - remainingFields -= 1; - summary[key] = summarize(nested, depth + 1); - } - return summary; - }; - try { - return summarize(event, 0); - } catch { - return { truncated: true }; - } -} - -/** Bounds traversal before adapters copy payloads or the logger encodes them. */ -export function boundProviderEventForLogging(event: unknown): unknown { - let remainingCharacters = MAX_RECORD_CHARACTERS; - let remainingFields = MAX_RECORD_FIELDS; - const ancestors = new WeakSet(); - const fits = (value: unknown, depth: number): boolean => { - if (typeof value === "string") { - remainingCharacters -= value.length; - return remainingCharacters >= 0; - } - if (typeof value !== "object" || value === null) return true; - if (depth > MAX_RECORD_DEPTH || ancestors.has(value)) return false; - if (Array.isArray(value) && value.length > remainingFields) return false; - ancestors.add(value); - for (const key in value) { - if (!Object.hasOwn(value, key)) continue; - remainingFields -= 1; - remainingCharacters -= key.length; - if ( - remainingFields < 0 || - remainingCharacters < 0 || - !fits(Reflect.get(value, key), depth + 1) - ) - return false; - } - ancestors.delete(value); - return true; - }; - try { - if (fits(event, 0)) return event; - } catch { - // A failing accessor must not escape into provider processing. - } - return summarizeProviderEvent(event); -} - -export function writeBatchedMessages( - sink: Pick, - records: ReadonlyArray, - maxBytes: number, - onWritten: (records: ReadonlyArray) => void, -): void { - let pendingRecords: Array = []; - let pendingBytes = 0; - - const flush = () => { - if (pendingRecords.length === 0) return; - const writtenRecords = pendingRecords; - sink.write(writtenRecords.map((record) => record.line).join("")); - onWritten(writtenRecords); - pendingRecords = []; - pendingBytes = 0; - }; - - for (const record of records) { - if (pendingBytes > 0 && pendingBytes + record.bytes > maxBytes) { - flush(); - } - pendingRecords.push(record); - pendingBytes += record.bytes; - if (pendingBytes >= maxBytes) { - flush(); - } - } - flush(); -} - -function isProviderLogFile(filePath: string, fileName: string, filePrefix: string): boolean { - if (!/\.log(?:\.\d+)?$/u.test(fileName)) return false; - if (fileName.startsWith(filePrefix)) return true; - - const descriptor = NodeFS.openSync(filePath, "r"); - try { - const header = Buffer.alloc(256); - const bytesRead = NodeFS.readSync(descriptor, header, 0, header.byteLength, 0); - return /^\[[^\]\r\n]+\] (?:NTIVE|CANON|ORCH): /u.test(header.toString("utf8", 0, bytesRead)); - } finally { - NodeFS.closeSync(descriptor); - } -} - -function enforceRetention(input: { - readonly directory: string; - readonly maxTotalBytes: number; - readonly maxAgeMs: number; - readonly activeFilePaths: ReadonlySet; - readonly filePrefix: string; - readonly now: number; -}): RetentionResult { - const failures: Array = []; - const files: Array<{ filePath: string; mtimeMs: number; size: number }> = []; - - let entries: ReadonlyArray; - try { - entries = NodeFS.readdirSync(input.directory, { withFileTypes: true }); - } catch (cause) { - return { failures: [{ filePath: input.directory, cause }] }; - } - - for (const entry of entries) { - if (!entry.isFile()) continue; - const filePath = NodePath.join(input.directory, entry.name); - try { - if (!isProviderLogFile(filePath, entry.name, input.filePrefix)) continue; - const stat = NodeFS.statSync(filePath); - files.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size }); - } catch (cause) { - failures.push({ filePath, cause }); - } - } - - let totalBytes = files.reduce((total, file) => total + file.size, 0); - const remove = (file: (typeof files)[number]) => { - if (input.activeFilePaths.has(file.filePath)) return false; - try { - NodeFS.rmSync(file.filePath, { force: true }); - totalBytes -= file.size; - return true; - } catch (cause) { - failures.push({ filePath: file.filePath, cause }); - return false; - } - }; - - const retained = files.filter((file) => { - if (input.now - file.mtimeMs <= input.maxAgeMs) return true; - return !remove(file); - }); - - for (const file of retained.toSorted( - (left, right) => left.mtimeMs - right.mtimeMs || left.filePath.localeCompare(right.filePath), - )) { - if (totalBytes <= input.maxTotalBytes) break; - remove(file); - } - - return { failures }; -} - -function validateOption(input: { - readonly filePath: string; - readonly option: string; - readonly value: number; - readonly minimum: number; -}): EventNdjsonLogConfigurationError | undefined { - if (Number.isInteger(input.value) && input.value >= input.minimum) return undefined; - return new EventNdjsonLogConfigurationError(input); -} - -function resolveOptions( - filePath: string, - options: EventNdjsonLogStoreOptions, -): Effect.Effect { - const resolved = { - maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES, - maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES, - batchWindowMs: options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS, - maxTotalBytes: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES, - maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS, - retentionCheckIntervalMs: - options.retentionCheckIntervalMs ?? DEFAULT_RETENTION_CHECK_INTERVAL_MS, - maxBufferedBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES, - maxBufferedRecords: options.maxBufferedRecords ?? DEFAULT_MAX_BUFFERED_RECORDS, - attribution: options.attribution, - } satisfies ResolvedOptions; - - const validations = [ - ["maxBytes", resolved.maxBytes, 1], - ["maxFiles", resolved.maxFiles, 1], - ["batchWindowMs", resolved.batchWindowMs, 0], - ["maxTotalBytes", resolved.maxTotalBytes, 1], - ["maxAgeMs", resolved.maxAgeMs, 1], - ["retentionCheckIntervalMs", resolved.retentionCheckIntervalMs, 1], - ["maxBufferedBytes", resolved.maxBufferedBytes, 1], - ["maxBufferedRecords", resolved.maxBufferedRecords, 1], - ] as const; - - for (const [option, value, minimum] of validations) { - const error = validateOption({ filePath, option, value, minimum }); - if (error) return Effect.fail(error); - } - return Effect.succeed(resolved); -} - -function drainPending(input: { - readonly directory: string; - readonly options: ResolvedOptions; - readonly state: StoreState; - readonly filePrefix: string; - readonly now: number; - readonly timerFired: boolean; - readonly close: boolean; -}): readonly [DrainResult, StoreState] { - if (input.state.closed) { - return [{ attributions: [], failures: [] }, input.state]; - } - - const sinks = new Map(input.state.sinks); - const failures: Array = []; - const attributionByStream = new Map< - EventNdjsonStream, - { count: number; logicalWriteBytes: number } - >(); - const recordsBySegment = new Map>(); - - for (const record of input.state.pending) { - const records = recordsBySegment.get(record.threadSegment) ?? []; - records.push(record); - recordsBySegment.set(record.threadSegment, records); - } - - for (const [threadSegment, records] of recordsBySegment) { - const filePath = providerLogPath(input.directory, input.filePrefix, threadSegment); - let sink = sinks.get(threadSegment); - if (!sink) { - try { - sink = new RotatingFileSink({ - filePath, - maxBytes: input.options.maxBytes, - maxFiles: input.options.maxFiles, - throwOnError: true, - }); - sinks.set(threadSegment, sink); - } catch (cause) { - failures.push({ filePath, cause }); - continue; - } - } - - try { - writeBatchedMessages(sink, records, input.options.maxBytes, (writtenRecords) => { - for (const record of writtenRecords) { - const current = attributionByStream.get(record.stream) ?? { - count: 0, - logicalWriteBytes: 0, - }; - attributionByStream.set(record.stream, { - count: current.count + 1, - logicalWriteBytes: current.logicalWriteBytes + record.bytes, - }); - } - }); - } catch (cause) { - sinks.delete(threadSegment); - failures.push({ filePath, cause }); - } - } - - const retentionDue = - input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs; - const retention = retentionDue - ? enforceRetention({ - directory: input.directory, - maxTotalBytes: input.options.maxTotalBytes, - maxAgeMs: input.options.maxAgeMs, - activeFilePaths: new Set( - Array.from(sinks.keys(), (threadSegment) => - providerLogPath(input.directory, input.filePrefix, threadSegment), - ), - ), - filePrefix: input.filePrefix, - now: input.now, - }) - : { failures: [] }; - - return [ - { - attributions: Array.from(attributionByStream, ([stream, value]) => ({ - stream, - ...value, - })), - failures: [...failures, ...retention.failures], - }, - { - pending: [], - pendingBytes: 0, - sinks, - flushScheduled: input.timerFired ? false : input.state.flushScheduled, - closed: input.close, - lastRetentionAt: retentionDue ? input.now : input.state.lastRetentionAt, - }, - ]; -} - -const serializeEvent = Effect.fnUntraced(function* (event: unknown) { - return yield* encodeUnknownJsonString(event).pipe( - Effect.catch((error) => - logWarning("failed to serialize provider event log record", { - errorTag: errorTag(error), - }).pipe(Effect.as(undefined)), - ), - ); -}); - -export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( - filePath: string, - options: EventNdjsonLogStoreOptions = {}, -): Effect.fn.Return { - const resolved = yield* resolveOptions(filePath, options); - const directory = NodePath.dirname(filePath); - const filePrefix = providerLogPrefix(filePath); - - yield* Effect.try({ - try: () => NodeFS.mkdirSync(directory, { recursive: true }), - catch: (cause) => new EventNdjsonLogDirectoryError({ directory, cause }), - }); - - const initializedAt = yield* Clock.currentTimeMillis; - const initialRetention = yield* Effect.sync(() => - enforceRetention({ - directory, - maxTotalBytes: resolved.maxTotalBytes, - maxAgeMs: resolved.maxAgeMs, - activeFilePaths: new Set(), - filePrefix, - now: initializedAt, - }), - ); - for (const failure of initialRetention.failures) { - yield* logWarning("provider event log retention failed", { - filePath: failure.filePath, - errorTag: errorTag(failure.cause), - }); - } - - const stateRef = yield* SynchronizedRef.make({ - pending: [], - pendingBytes: 0, - sinks: new Map(), - flushScheduled: false, - closed: false, - lastRetentionAt: initializedAt, - }); - const timerScope = yield* Scope.make(); - - const flush = Effect.fnUntraced(function* (timerFired: boolean, close: boolean) { - const startedAt = yield* Clock.currentTimeMillis; - const result = yield* SynchronizedRef.modifyEffect(stateRef, (state) => - Effect.sync(() => - drainPending({ - directory, - options: resolved, - state, - filePrefix, - now: startedAt, - timerFired, - close, - }), - ), - ); - - for (const failure of result.failures) { - yield* logWarning("provider event log write or retention failed", { - filePath: failure.filePath, - errorTag: errorTag(failure.cause), - }); - } - - if (resolved.attribution && result.attributions.length > 0) { - const completedAt = yield* Clock.currentTimeMillis; - const durationMs = Math.max(0, completedAt - startedAt); - const totalBytes = result.attributions.reduce( - (total, entry) => total + entry.logicalWriteBytes, - 0, - ); - yield* Effect.forEach( - result.attributions, - (entry) => - resolved.attribution?.record({ - component: "provider-event-log", - operation: `${entry.stream}.append`, - logicalWriteBytes: entry.logicalWriteBytes, - count: entry.count, - durationMs: - totalBytes === 0 - ? 0 - : Math.round(durationMs * (entry.logicalWriteBytes / totalBytes)), - }) ?? Effect.void, - { discard: true }, - ); - } - }); - - const scheduleFlush = Effect.fnUntraced(function* () { - yield* Effect.forkIn( - Effect.sleep(resolved.batchWindowMs).pipe(Effect.andThen(flush(true, false))), - timerScope, - { startImmediately: true }, - ); - }); - - const close = Effect.fnUntraced(function* () { - yield* flush(false, true); - yield* Scope.close(timerScope, Exit.void); - }); - - const loggerViews = new Map(); - const logger = (stream: EventNdjsonStream): EventNdjsonLogger => { - const existing = loggerViews.get(stream); - if (existing) return existing; - - const write = Effect.fnUntraced(function* (event: unknown, threadId: ThreadId | null) { - if (!shouldPersistProviderEvent(stream, event)) return; - let payload = yield* serializeEvent(boundProviderEventForLogging(event)); - if (payload === undefined) return; - // Escaping can expand strings beyond their input size. Keep that bounded - // serialization out of the file too, while retaining routing/error fields. - if (Buffer.byteLength(payload) > MAX_RECORD_CHARACTERS) { - payload = yield* serializeEvent(summarizeProviderEvent(event)); - if (payload === undefined) return; - } - - const observedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); - const line = `[${observedAt}] ${resolveStreamLabel(stream)}: ${payload}\n`; - const bytes = Buffer.byteLength(line); - const action = yield* SynchronizedRef.modifyEffect(stateRef, (state) => { - if (state.closed) { - return Effect.succeed([{ flush: false }, state] as const); - } - const pending = state.pending; - pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes }); - const pendingBytes = state.pendingBytes + bytes; - const flush = - resolved.batchWindowMs === 0 || - pending.length >= resolved.maxBufferedRecords || - pendingBytes >= resolved.maxBufferedBytes; - const schedule = !flush && !state.flushScheduled; - const nextState = { - ...state, - pending, - pendingBytes, - flushScheduled: state.flushScheduled || schedule, - }; - return (schedule ? scheduleFlush() : Effect.void).pipe( - Effect.as([{ flush }, nextState] as const), - ); - }).pipe(Effect.uninterruptible); - - if (action.flush) { - yield* flush(false, false); - } - }); - - const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger; - loggerViews.set(stream, view); - return view; - }; - - return { filePath, logger, close } satisfies EventNdjsonLogStore; -}); - -export const makeEventNdjsonLogger = Effect.fnUntraced(function* ( - filePath: string, - options: EventNdjsonLoggerOptions, -): Effect.fn.Return { - const store = yield* makeEventNdjsonLogStore(filePath, options).pipe( - Effect.catch((error) => - logWarning(error.message, { error }).pipe( - Effect.as(undefined), - ), - ), - ); - if (!store) return undefined; - return { ...store.logger(options.stream), close: store.close }; -}); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts deleted file mode 100644 index c0bf61dc40fa5239f4733974805648aeacbb14d8..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ /dev/null @@ -1,845 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - resolves the mock ACP agent script path relative to this test file. -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientResponse } from "effect/http"; -import { GrokSettings } from "@t3tools/contracts"; - -import { - buildGrokModelCapabilities, - buildGrokModelsFromSessionModelState, - buildInitialGrokProviderSnapshot, - checkGrokProviderStatus, - grokSlashCommandsFromInitialize, - parseGrokModelsCliOutput, -} from "./GrokProvider.ts"; -import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; -import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; - -const decodeGrokSettings = Schema.decodeSync(GrokSettings); -const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); - -const LOGGED_IN_MODELS_OUTPUT = [ - "You are logged in with grok.com.", - "", - "Default model: grok-4.6", - "", - "Available models:", - " * grok-4.6 (default)", - " - grok-4.5", - "", -].join("\n"); - -const LOGGED_OUT_MODELS_OUTPUT = LOGGED_IN_MODELS_OUTPUT.replace( - "You are logged in with grok.com.", - "You are not authenticated.", -); - -describe("parseGrokModelsCliOutput", () => { - it("reads login state and model slugs, marking the default", () => { - const parsed = parseGrokModelsCliOutput(LOGGED_IN_MODELS_OUTPUT); - expect(parsed.authenticated).toBe(true); - expect(parsed.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ - ["grok-4.6", true], - ["grok-4.5", false], - ]); - }); - - it("detects a logged-out CLI even though it exits 0", () => { - expect(parseGrokModelsCliOutput(LOGGED_OUT_MODELS_OUTPUT).authenticated).toBe(false); - }); - - it("returns unknown auth for unrecognized output", () => { - expect(parseGrokModelsCliOutput("grok 9.9.9\n").authenticated).toBeNull(); - }); -}); - -describe("grokSlashCommandsFromInitialize", () => { - it("publishes native ACP commands and input hints without permission overrides", () => { - const commands = grokSlashCommandsFromInitialize({ - protocolVersion: 1, - _meta: { - availableCommands: [ - { name: "compact", description: "Compress history", input: { hint: "what to preserve" } }, - { - name: "always-approve", - description: "Skip permission prompts", - input: { hint: "on|off" }, - }, - { name: "context", description: "Show context usage", input: null }, - { name: "session-info", description: "Show session details" }, - { name: "deep-research", description: "Research a topic", input: { hint: "" } }, - { name: "workflow", description: "Manage workflows", input: { hint: "runs" } }, - { name: "goal", description: "Manage an autonomous goal", input: { hint: "status" } }, - ], - }, - }); - expect(commands.map((command) => command.name)).toEqual([ - "compact", - "session-info", - "deep-research", - "workflow", - "goal", - ]); - expect(commands[0]?.input).toEqual({ hint: "what to preserve" }); - expect(commands[2]).toEqual({ - name: "deep-research", - description: "Research a topic", - input: { hint: "" }, - }); - }); - - it("keeps valid commands when other metadata entries are malformed", () => { - const commands = grokSlashCommandsFromInitialize({ - protocolVersion: 1, - _meta: { - availableCommands: [ - null, - { name: "broken", description: 42 }, - { name: " ", description: "Empty name" }, - { name: " session-info ", description: " Session details " }, - { name: "session-info", description: "Updated session details" }, - ], - }, - }); - expect(commands.map((command) => command.name)).toEqual(["compact", "session-info"]); - expect(commands[1]?.description).toBe("Updated session details"); - }); - - it("keeps compact available for older agents without command metadata", () => { - for (const _meta of [undefined, {}, { availableCommands: "invalid" }]) { - expect( - grokSlashCommandsFromInitialize({ protocolVersion: 1, ...(_meta ? { _meta } : {}) }).map( - (command) => command.name, - ), - ).toEqual(["compact"]); - } - }); -}); - -describe("buildGrokModelsFromSessionModelState", () => { - it("marks the agent's current model as default and keeps reasoning options", () => { - const models = buildGrokModelsFromSessionModelState({ - currentModelId: "grok-4.6", - availableModels: [ - { - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: true, - reasoningEffort: "high", - reasoningEfforts: [{ value: "high", label: "High", default: true }], - }, - }, - { modelId: "grok-4.5", name: "Grok 4.5" }, - ], - }); - expect(models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ - ["grok-4.6", true], - ["grok-4.5", false], - ]); - expect(models[0]?.capabilities?.optionDescriptors).toHaveLength(1); - }); -}); - -describe("buildGrokModelCapabilities", () => { - it("preserves ACP-provided reasoning labels and the active default", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: true, - reasoningEffort: "xhigh", - reasoningEfforts: [ - { value: "xhigh", label: "Extra High Effort", default: true }, - { value: "high", label: "High Effort", default: true }, - { value: "medium", label: "Medium Effort" }, - { value: "low", label: "Low Effort" }, - ], - }, - }); - - expect(capabilities.optionDescriptors).toEqual([ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - currentValue: "xhigh", - options: [ - { id: "xhigh", label: "Extra High Effort", isDefault: true }, - { id: "high", label: "High Effort" }, - { id: "medium", label: "Medium Effort" }, - { id: "low", label: "Low Effort" }, - ], - }, - ]); - }); - - it("uses raw ACP values when option labels are omitted", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: true, - reasoningEffort: "xhigh", - reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }], - }, - }); - - expect(capabilities.optionDescriptors).toEqual([ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - currentValue: "xhigh", - options: [ - { id: "xhigh", label: "xhigh" }, - { id: "medium", label: "medium" }, - ], - }, - ]); - }); - - it("keeps ACP current effort separate from its collapsed advertised default", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: true, - reasoningEffort: "medium", - reasoningEfforts: [ - { value: "xhigh", label: "Extra High Effort", default: true }, - { value: "high", label: "High Effort", default: true }, - { value: "medium", label: "Medium Effort" }, - ], - }, - }); - - expect(capabilities.optionDescriptors).toEqual([ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - currentValue: "medium", - options: [ - { id: "xhigh", label: "Extra High Effort", isDefault: true }, - { id: "high", label: "High Effort" }, - { id: "medium", label: "Medium Effort" }, - ], - }, - ]); - }); - - it("preserves ACP descriptions and falls back from invalid values to valid ids", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: true, - reasoningEffort: "high", - reasoningEfforts: [ - { - id: "high", - value: "not a token", - label: "High Effort", - description: "Higher implementation quality", - default: true, - }, - { id: "bad id", value: "also invalid", label: "Invalid" }, - ], - }, - }); - - expect(capabilities.optionDescriptors).toEqual([ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - currentValue: "high", - options: [ - { - id: "high", - label: "High Effort", - description: "Higher implementation quality", - isDefault: true, - }, - ], - }, - ]); - }); - - it("accepts an advertised ACP menu when the support flag is omitted", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - reasoningEffort: "high", - reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], - }, - }); - - expect(capabilities.optionDescriptors).toHaveLength(1); - }); - - it("honors an explicit ACP opt-out even when a menu is present", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: false, - reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], - }, - }); - - expect(capabilities.optionDescriptors).toEqual([]); - }); - - it("does not synthesize a reasoning menu when ACP omits it", () => { - expect( - buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" }, - }).optionDescriptors, - ).toEqual([]); - }); - - it("keeps non-reasoning Grok models free of reasoning controls", () => { - expect( - buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, - ).toEqual([]); - }); -}); - -describe("buildInitialGrokProviderSnapshot", () => { - it.effect("returns a disabled snapshot when settings.enabled is false", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialGrokProviderSnapshot( - decodeGrokSettings({ enabled: false }), - ); - expect(snapshot.enabled).toBe(false); - expect(snapshot.status).toBe("disabled"); - expect(snapshot.installed).toBe(false); - expect(snapshot.message).toContain("disabled"); - }), - ); - - it.effect("returns a disabled snapshot by default — Grok is opt-in", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({})); - expect(snapshot.enabled).toBe(false); - expect(snapshot.status).toBe("disabled"); - }), - ); - - it.effect("returns a pending snapshot when enabled", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialGrokProviderSnapshot( - decodeGrokSettings({ enabled: true }), - ); - expect(snapshot.enabled).toBe(true); - expect(snapshot.installed).toBe(true); - expect(snapshot.status).toBe("warning"); - expect(snapshot.version).toBeNull(); - expect(snapshot.message).toContain("Checking Grok"); - expect(snapshot.requiresNewThreadForModelChange).toBeUndefined(); - expect(snapshot.supportsConversationRollback).toBe(false); - }), - ); -}); - -it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { - it.effect("reports the binary as missing when the binary path does not resolve", () => - Effect.gen(function* () { - const snapshot = yield* checkGrokProviderStatus( - decodeGrokSettings({ - enabled: true, - binaryPath: "/definitely/not/installed/grok-binary", - }), - ); - expect(snapshot.enabled).toBe(true); - expect(snapshot.installed).toBe(false); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); - }), - ); - - it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => - Effect.gen(function* () { - const secretStderr = "broken grok install: secret-token-value"; - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); - const grokPath = writeFakeCli({ - directory: dir, - name: "grok", - source: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`, - "process.exit(2);", - "", - ].join("\n"), - }); - - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - ); - }), - ); - - expect(snapshot.enabled).toBe(true); - expect(snapshot.installed).toBe(true); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).toBe("Grok CLI is installed but failed to run."); - expect(snapshot.message).not.toContain(secretStderr); - }), - ); - - // A stand-in for the Grok CLI: `--version` and `models` print canned text, - // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. - const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); - const mockAgentPath = NodePath.resolve(__dirname, "../../../scripts/acp-mock-agent.ts"); - return writeFakeCli({ - directory: dir, - name: "grok", - source: [ - 'if (process.argv[2] === "--version") {', - ' process.stdout.write("grok 1.0.13\\n");', - " process.exit(0);", - "}", - 'if (process.argv[2] === "models") {', - // @effect-diagnostics-next-line preferSchemaOverJson:off - ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`, - " process.exit(0);", - "}", - 'if (process.argv[2] !== "agent") process.exit(1);', - ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]), - "", - ].join("\n"), - }); - }); - - it.effect("reports ready with ACP-discovered models when logged in", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const grokPath = yield* writeFakeGrokCli({ - modelsOutput: LOGGED_IN_MODELS_OUTPUT, - acp: true, - }); - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - { ...process.env, XAI_API_KEY: "" }, - ); - }), - ); - - expect(snapshot.status).toBe("ready"); - expect(snapshot.version).toBe("1.0.13"); - expect(snapshot.auth).toEqual({ - status: "authenticated", - type: "cached_token", - label: "Grok account", - }); - // The mock agent advertises grok-4.6 with reasoning options in initialize._meta. - expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); - expect(snapshot.models[0]?.isDefault).toBe(true); - expect( - snapshot.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id) ?? [], - ).toEqual(["reasoningEffort"]); - }), - ); - - it.effect("reports unauthenticated from `grok models` without starting a session", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const grokPath = yield* writeFakeGrokCli({ - modelsOutput: LOGGED_OUT_MODELS_OUTPUT, - acp: true, - }); - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - { ...process.env, XAI_API_KEY: "" }, - ); - }), - ); - - expect(snapshot.status).toBe("error"); - expect(snapshot.auth.status).toBe("unauthenticated"); - expect(snapshot.message).toContain("grok login"); - expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); - }), - ); - - it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const grokPath = yield* writeFakeGrokCli({ - modelsOutput: LOGGED_IN_MODELS_OUTPUT, - acp: false, - }); - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - { ...process.env, XAI_API_KEY: "" }, - ); - }), - ); - - expect(snapshot.status).toBe("warning"); - expect(snapshot.installed).toBe(true); - expect(snapshot.auth.status).toBe("authenticated"); - expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ - ["grok-4.6", true], - ["grok-4.5", false], - ]); - expect(snapshot.message).toContain("ACP initialize failed"); - expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["compact"]); - }), - ); - - it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const grokPath = yield* writeFakeGrokCli({ - modelsOutput: LOGGED_OUT_MODELS_OUTPUT, - acp: false, - }); - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - { ...process.env, XAI_API_KEY: "xai-test-key" }, - ); - }), - ); - - expect(snapshot.auth).toEqual({ - status: "authenticated", - type: "api_key", - label: "xAI API key", - }); - expect(snapshot.status).toBe("warning"); - }), - ); -}); - -describe("Grok usage limits", () => { - const checkedAt = "2026-09-16T00:00:00.000Z"; - - it("uses the reported subscription percentage and weekly reset", () => { - const limits = grokUsageResponseToLimits( - { - config: { - creditUsagePercent: 100, - currentPeriod: { - type: "USAGE_PERIOD_TYPE_WEEKLY", - end: "2026-09-18T03:10:30.159171+00:00", - }, - }, - }, - checkedAt, - ); - expect(limits.windows).toEqual([ - { - id: "subscription", - kind: "weekly", - label: "Weekly", - usedPercent: 100, - resetsAt: "2026-09-18T03:10:30.159Z", - }, - ]); - }); - - it("does not invent allowance or reset dates when billing omits them", () => { - for (const response of [{}, { config: {} }, { config: { creditUsagePercent: NaN } }]) { - const limits = grokUsageResponseToLimits(response, checkedAt); - expect(limits.windows).toEqual([]); - // Nothing metered yet, which xAI reports by omitting the field until - // usage registers. Marking it `unsupported` would drop the account from - // the Limits view for good; leaving the marker off keeps it listed. - expect(limits.unavailable).toBeUndefined(); - } - expect( - grokUsageResponseToLimits({ config: { creditUsagePercent: 0 } }, checkedAt).windows, - ).toEqual([{ id: "subscription", kind: "other", label: "Subscription", usedPercent: 0 }]); - expect( - grokUsageResponseToLimits( - { - config: { - creditUsagePercent: 120, - currentPeriod: { type: "USAGE_PERIOD_TYPE_MONTHLY", end: "invalid" }, - }, - }, - checkedAt, - ).windows, - ).toEqual([{ id: "subscription", kind: "monthly", label: "Monthly", usedPercent: 100 }]); - }); -}); - -it.layer(NodeServices.layer)("readGrokAccount", (it) => { - it.effect("reads the configured Grok home and prefers the current login scope to legacy", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped(); - yield* fs.writeFileString( - NodePath.join(directory, "auth.json"), - '{"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828":{"key":"session-token","auth_mode":"oauth"},"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', - ); - const { usageLimits: limits } = yield* readGrokAccount({ - GROK_HOME: directory, - HOME: "/unrelated-home", - }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(request.method).toBe("GET"); - expect(request.url).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits"); - expect(request.headers.authorization).toBe("Bearer session-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ config: { creditUsagePercent: 37 } }), - ), - ); - }), - ), - ); - expect(limits.windows[0]?.usedPercent).toBe(37); - }).pipe(Effect.scoped), - ); - - it.effect( - "uses GROK_AUTH without reading stored credentials and accepts the legacy login scope", - () => - readGrokAccount({ - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: (path) => - path.endsWith("auth.json") - ? Effect.die("must not read stored credentials") - : Effect.succeed(""), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(request.headers.authorization).toBe("Bearer legacy-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ config: { creditUsagePercent: 12 } }), - ), - ); - }), - ), - Effect.tap(({ usageLimits }) => - Effect.sync(() => expect(usageLimits.windows[0]?.usedPercent).toBe(12)), - ), - ), - ); - - it.effect("does not use unrelated scopes, API keys, or custom auth deployments", () => - Effect.gen(function* () { - for (const environment of [ - { - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', - XAI_API_KEY: "different-api-account", - }, - { GROK_AUTH: '{"https://other.example":{"key":"unrelated-token"}}' }, - { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"api-key","auth_mode":"api_key"}}' }, - { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":" "}}' }, - { - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', - GROK_OIDC_ISSUER: "https://custom.example", - }, - { - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', - GROK_MODELS_BASE_URL: "https://custom.example/v1", - }, - { - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', - GROK_OAUTH2_PRINCIPAL_TYPE: "Team", - }, - { - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', - GROK_OAUTH2_PRINCIPAL_ID: "team-id", - }, - { - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', - GROK_CONFIG: '{"auth_provider_command":"custom-auth"}', - }, - { - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', - GROK_CONFIG_PATH: "/custom-config.toml", - }, - ]) { - const { usageLimits: limits } = yield* readGrokAccount({ - HOME: "/definitely/not/a/grok-home", - ...environment, - }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request another account's quota")), - ), - ); - expect(limits.windows).toEqual([]); - expect(limits.unavailable?.reason).toBe("unsupported"); - } - }), - ); - - it.effect( - "reports a missing login as unsupported and malformed credentials as a sanitized failure", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped(); - const client = HttpClient.make(() => Effect.die("must not request without credentials")); - const { usageLimits: missing } = yield* readGrokAccount({ HOME: directory }).pipe( - Effect.provideService(HttpClient.HttpClient, client), - ); - expect(missing.unavailable?.reason).toBe("unsupported"); - for (const contents of [ - "private-token-invalid-json", - '{"https://accounts.x.ai/sign-in":{"key":42}}', - ]) { - const { usageLimits: malformed } = yield* readGrokAccount({ - GROK_HOME: directory, - GROK_AUTH: contents, - }).pipe(Effect.provideService(HttpClient.HttpClient, client)); - expect(malformed.unavailable).toEqual({ - reason: "probeFailed", - message: "Grok could not read usage limits.", - }); - } - }).pipe(Effect.scoped), - ); - - it.effect( - "does not request subscription limits for custom account or endpoint configuration", - () => - Effect.gen(function* () { - for (const config of [ - '[auth]\nprovider_command = "custom-auth"', - '[grok_com_config]\nissuer = "https://custom.example"', - 'endpoints.proxy = "https://custom.example"', - ]) { - const { usageLimits: limits } = yield* readGrokAccount({ - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}', - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: (path) => { - expect(path.endsWith("config.toml")).toBe(true); - return Effect.succeed(config); - }, - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request another account's quota")), - ), - ); - expect(limits.windows).toEqual([]); - expect(limits.unavailable?.reason).toBe("unsupported"); - } - }), - ); - - it.effect("sanitizes HTTP failures and malformed billing responses, keeping the account", () => - Effect.gen(function* () { - for (const response of [ - new Response("private response", { status: 401 }), - Response.json({ config: { creditUsagePercent: "private-value" } }), - ]) { - const { email, usageLimits: limits } = yield* readGrokAccount({ - HOME: "/definitely/not/a/grok-home", - GROK_AUTH: - '{"https://accounts.x.ai/sign-in":{"key":"private-token","email":"someone@example.com"}}', - }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, response)), - ), - ), - ); - expect(email).toBe("someone@example.com"); - expect(limits.windows).toEqual([]); - expect(limits.unavailable).toEqual({ - reason: "probeFailed", - message: "Grok could not read usage limits.", - }); - } - }), - ); -}); - -it.layer(NodeServices.layer)("readGrokAccount email", (it) => { - it.effect("names the account whose limits were read, and nothing for other auth", () => - Effect.gen(function* () { - const current = '"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"'; - const cases = [ - [ - { GROK_AUTH: `{${current}:{"key":"t","email":" Someone@Example.com "}}` }, - "Someone@Example.com", - ], - [ - { - GROK_AUTH: `{${current}:{"key":"t","email":"current@example.com"},"https://accounts.x.ai/sign-in":{"key":"t","email":"legacy@example.com"}}`, - }, - "current@example.com", - ], - [{ GROK_AUTH: `{${current}:{"key":"t"}}` }, undefined], - [ - { - GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com"}}`, - XAI_API_KEY: "api-key", - }, - undefined, - ], - [ - { - GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com","auth_mode":"api_key"}}`, - }, - undefined, - ], - [{ GROK_AUTH: "not-json" }, undefined], - ] as const; - for (const [environment, expected] of cases) { - const { email, usageLimits } = yield* readGrokAccount({ - HOME: "/definitely/not/a/grok-home", - ...environment, - }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ config: { creditUsagePercent: 25 } }), - ), - ), - ), - ), - ); - expect(email).toBe(expected); - if (expected) expect(usageLimits.windows[0]?.usedPercent).toBe(25); - } - }), - ); -}); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts deleted file mode 100644 index 2e470a3caef305273c500609c9ce3c711f38a86a..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ /dev/null @@ -1,586 +0,0 @@ -import { - type CustomModelSetting, - type GrokSettings, - type ModelCapabilities, - type ServerProvider, - type ServerProviderAuth, - type ServerProviderModel, - type ServerProviderSlashCommand, -} from "@t3tools/contracts"; -import type * as EffectAcpSchema from "effect-acp/compat"; -import { causeErrorTag } from "@t3tools/shared/observability"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { createModelCapabilities } from "@t3tools/shared/model"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; - -import { - AUTH_PROBE_TIMEOUT_MS, - buildServerProvider, - COMPACT_SLASH_COMMAND, - isCommandMissingCause, - parseGenericCliVersion, - providerModelsFromSettings, - spawnAndCollect, - type ServerProviderDraft, -} from "../providerSnapshot.ts"; -import { - enrichProviderSnapshotWithVersionAdvisory, - type ProviderMaintenanceCapabilities, -} from "../providerMaintenance.ts"; -import { - GROK_DEFAULT_MODEL_SLUG, - GROK_SUPPORTED_RUNTIME_MODES, - isValidGrokReasoningEffortToken, - makeGrokAcpRuntime, - resolveGrokAcpBaseModelId, -} from "../acp/GrokAcpSupport.ts"; -import { sessionModelStateFromInitialize } from "../acp/AcpRuntimeModel.ts"; -import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; - -const GROK_PRESENTATION = { - displayName: "Grok", - supportsConversationRollback: false, - showInteractionModeToggle: false, - supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES, -} as const; -const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); - -const VERSION_PROBE_TIMEOUT_MS = 4_000; -// `initialize` is a single local round trip, so this is generous even on slow machines. -const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000; -const GROK_API_KEY_ENV = "XAI_API_KEY"; - -const GROK_BUILT_IN_MODELS: ReadonlyArray = [ - { - slug: GROK_DEFAULT_MODEL_SLUG, - name: "Grok Build", - isCustom: false, - capabilities: EMPTY_CAPABILITIES, - }, -]; - -export function buildInitialGrokProviderSnapshot( - grokSettings: GrokSettings, -): Effect.Effect { - return Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = grokModelsFromSettings(grokSettings.customModels); - - if (!grokSettings.enabled) { - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Grok is disabled in T3 Code settings.", - }, - }); - } - - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: true, - checkedAt, - models, - probe: { - installed: true, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Checking Grok CLI availability...", - }, - }); - }); -} - -function grokModelsFromSettings( - customModels: ReadonlyArray | undefined, - builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, -): ReadonlyArray { - return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); -} - -function nonEmptyString(value: unknown): string | undefined { - return typeof value === "string" ? value.trim() || undefined : undefined; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { - readonly options: ReadonlyArray<{ - value: string; - label: string; - description?: string; - isDefault?: boolean; - }>; - readonly currentValue: string | undefined; -} { - const meta = model._meta; - if (!meta || meta.supportsReasoningEffort === false) { - return { options: [], currentValue: undefined }; - } - - const currentEffort = nonEmptyString(meta.reasoningEffort); - const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : []; - const seen = new Set(); - const options: Array<{ - value: string; - label: string; - description?: string; - advertisedDefault: boolean; - }> = []; - - for (const entry of advertisedOptions) { - if (!isRecord(entry)) { - continue; - } - const rawValue = nonEmptyString(entry.value); - const rawId = nonEmptyString(entry.id); - const value = - rawValue && isValidGrokReasoningEffortToken(rawValue) - ? rawValue - : rawId && isValidGrokReasoningEffortToken(rawId) - ? rawId - : undefined; - if (value === undefined || seen.has(value)) { - continue; - } - seen.add(value); - const description = nonEmptyString(entry.description); - options.push({ - value, - label: nonEmptyString(entry.label) ?? value, - ...(description ? { description } : {}), - advertisedDefault: entry.default === true || entry.isDefault === true, - }); - } - - const currentValue = - currentEffort && options.some((option) => option.value === currentEffort) - ? currentEffort - : undefined; - const advertisedDefaults = options.filter((option) => option.advertisedDefault); - const selectedDefault = - advertisedDefaults.find((option) => option.value === currentValue)?.value ?? - advertisedDefaults[0]?.value; - return { - options: options.map(({ value, label, description }) => ({ - value, - label, - ...(description ? { description } : {}), - ...(value === selectedDefault ? { isDefault: true } : {}), - })), - currentValue: currentValue ?? selectedDefault, - }; -} - -export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities { - const reasoning = grokReasoningOptionsFromModel(model); - return reasoning.options.length > 0 - ? createModelCapabilities({ - optionDescriptors: [ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - options: reasoning.options.map((option) => ({ - id: option.value, - label: option.label, - ...(option.description ? { description: option.description } : {}), - ...(option.isDefault ? { isDefault: true } : {}), - })), - ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}), - }, - ], - }) - : EMPTY_CAPABILITIES; -} - -/** Models advertised by the ACP agent, with the session's current model marked as default. */ -export function buildGrokModelsFromSessionModelState( - modelState: EffectAcpSchema.SessionModelState | null | undefined, -): ReadonlyArray { - if (!modelState || modelState.availableModels.length === 0) { - return []; - } - const currentModelId = modelState.currentModelId.trim(); - const seen = new Set(); - return modelState.availableModels.flatMap((model): ServerProviderModel[] => { - const slug = resolveGrokAcpBaseModelId(model.modelId); - if (!slug || seen.has(slug)) { - return []; - } - seen.add(slug); - return [ - { - slug, - name: model.name.trim() || slug, - isCustom: false, - ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}), - capabilities: buildGrokModelCapabilities(model), - }, - ]; - }); -} - -export interface GrokModelsCliOutput { - /** True or false when the CLI printed a login line, null when it printed neither. */ - readonly authenticated: boolean | null; - readonly models: ReadonlyArray; -} - -/** - * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the - * text is the only signal. Current output looks like: - * - * You are logged in with grok.com. - * Default model: grok-4.6 - * Available models: - * * grok-4.6 (default) - * - grok-4.5 - */ -export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput { - const authenticated = /you are logged in/i.test(output) - ? true - : /not authenticated|not logged in/i.test(output) - ? false - : null; - - const seen = new Set(); - const models: ServerProviderModel[] = []; - for (const line of output.split(/\r?\n/)) { - const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/); - if (!bullet?.[1]) { - continue; - } - const slug = resolveGrokAcpBaseModelId(bullet[1]); - if (seen.has(slug)) { - continue; - } - seen.add(slug); - models.push({ - slug, - name: displayNameFromGrokModelSlug(slug), - isCustom: false, - ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}), - capabilities: EMPTY_CAPABILITIES, - }); - } - return { authenticated, models }; -} - -function displayNameFromGrokModelSlug(slug: string): string { - return slug - .split(/[-_]/g) - .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part)) - .join(" "); -} - -const runGrokCliCommand = ( - grokSettings: GrokSettings, - args: ReadonlyArray, - environment: NodeJS.ProcessEnv, -) => - Effect.gen(function* () { - const command = grokSettings.binaryPath || "grok"; - const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); - return yield* spawnAndCollect( - command, - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - env: environment, - shell: spawnCommand.shell, - }), - ); - }); - -const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown)); -const decodeAvailableCommand = Schema.decodeUnknownOption( - Schema.Struct({ - name: Schema.String, - description: Schema.String, - input: Schema.optional(Schema.NullOr(Schema.Struct({ hint: Schema.String }))), - }), -); - -export function grokSlashCommandsFromInitialize( - initialized: EffectAcpSchema.InitializeResponse, -): ReadonlyArray { - const commands = decodeAvailableCommands(initialized._meta?.availableCommands); - const byName = new Map([ - [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND], - ]); - for (const entry of Option.getOrElse(commands, () => [])) { - const decoded = decodeAvailableCommand(entry); - if (Option.isNone(decoded)) continue; - const command = decoded.value; - const name = command.name.trim(); - // Permission changes must go through T3 so the client and provider agree. - if (!name || name.toLowerCase() === "always-approve") continue; - // Grok advertises /context, but its ACP handler completes without emitting output. - if (name.toLowerCase() === "context") continue; - const description = command.description.trim(); - const hint = command.input?.hint.trim(); - byName.set(name, { - name, - ...(description ? { description } : {}), - ...(hint ? { input: { hint } } : {}), - }); - } - return [...byName.values()]; -} - -/** - * Reads model and command metadata from `initialize._meta`. This never calls `authenticate` - * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers. - */ -const discoverGrokMetadataViaAcpInitialize = ( - grokSettings: GrokSettings, - environment: NodeJS.ProcessEnv, -) => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const acp = yield* makeGrokAcpRuntime({ - grokSettings, - environment, - childProcessSpawner, - cwd: process.cwd(), - clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, - }); - const initialized = yield* acp.initialize(); - return { - models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)), - slashCommands: grokSlashCommandsFromInitialize(initialized), - }; - }).pipe(Effect.scoped); - -export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( - grokSettings: GrokSettings, - environment: NodeJS.ProcessEnv = process.env, - cwd?: string, -): Effect.fn.Return< - ServerProviderDraft, - never, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto -> { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const fallbackModels = grokModelsFromSettings(grokSettings.customModels); - - if (!grokSettings.enabled) { - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: false, - checkedAt, - models: fallbackModels, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Grok is disabled in T3 Code settings.", - }, - }); - } - - const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe( - Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(versionResult)) { - const error = versionResult.failure; - yield* Effect.logWarning("Grok CLI health check failed.", { - errorTag: error._tag, - }); - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: !isCommandMissingCause(error), - version: null, - status: "error", - auth: { status: "unknown" }, - message: isCommandMissingCause(error) - ? "Grok CLI (`grok`) is not installed or not on PATH." - : "Failed to execute Grok CLI health check.", - }, - }); - } - - if (Option.isNone(versionResult.success)) { - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: "Grok CLI is installed but timed out while running `grok --version`.", - }, - }); - } - - const versionOutput = versionResult.success.value; - const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); - if (versionOutput.code !== 0) { - yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", { - exitCode: versionOutput.code, - stdoutLength: versionOutput.stdout.length, - stderrLength: versionOutput.stderr.length, - }); - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: "Grok CLI is installed but failed to run.", - }, - }); - } - - // `grok models` reports login state and model slugs without starting the agent. - const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe( - Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), - Effect.result, - ); - // Only a clean exit is parsed. Failed invocations print help or error text that - // must not be read as model slugs or as a login verdict. - const modelsOutput = - Result.isSuccess(modelsResult) && - Option.isSome(modelsResult.success) && - modelsResult.success.value.code === 0 - ? modelsResult.success.value - : undefined; - const cliModels: GrokModelsCliOutput = modelsOutput - ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`) - : { authenticated: null, models: [] }; - if (!modelsOutput) { - yield* Effect.logWarning("Grok CLI model listing failed or timed out.", { - errorTag: Result.isFailure(modelsResult) - ? modelsResult.failure._tag - : Option.isNone(modelsResult.success) - ? "Timeout" - : `ExitCode${modelsResult.success.value.code}`, - }); - } - - const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim() - ? { status: "authenticated", type: "api_key", label: "xAI API key" } - : cliModels.authenticated === true - ? { status: "authenticated", type: "cached_token", label: "Grok account" } - : cliModels.authenticated === false - ? { status: "unauthenticated" } - : { status: "unknown" }; - - const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe( - Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })), - Effect.orElseSucceed(() => []), - ); - - const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, environment).pipe( - Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS), - Effect.exit, - ); - const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined; - const acpModels = acpMetadata?.models ?? []; - const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); - if (acpFailed) { - yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", { - errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout", - }); - } - - const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models; - const models = - discoveredModels.length > 0 - ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) - : fallbackModels; - - if (auth.status === "unauthenticated") { - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models, - skills, - probe: { - installed: true, - version, - status: "error", - auth, - message: "Grok CLI is installed but not logged in. Run `grok login`.", - }, - }); - } - - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models, - skills, - slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND], - probe: { - installed: true, - version, - // A failed metadata probe degrades the model picker, it does not make chats fail. - status: acpFailed ? "warning" : "ready", - auth, - ...(acpFailed - ? { - message: - "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", - } - : {}), - }, - }); -}); - -export const enrichGrokSnapshot = (input: { - readonly snapshot: ServerProvider; - readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; - readonly enableProviderUpdateChecks?: boolean; - readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; - readonly httpClient: HttpClient.HttpClient; -}): Effect.Effect => { - const { snapshot, publishSnapshot } = input; - - return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { - enableProviderUpdateChecks: input.enableProviderUpdateChecks, - }).pipe( - Effect.provideService(HttpClient.HttpClient, input.httpClient), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - Effect.catchCause((cause) => - Effect.logWarning("Grok version advisory enrichment failed", { - errorTag: causeErrorTag(cause), - }), - ), - Effect.asVoid, - ); -}; diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts deleted file mode 100644 index e188501f40cd26bfd87c34a1d84cd5d967021204..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ /dev/null @@ -1,873 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; -import * as NodeCrypto from "node:crypto"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/http"; -import { beforeEach } from "vite-plus/test"; - -import { OpenCodeSettings } from "@t3tools/contracts"; -import * as ServerConfig from "../../config.ts"; -import * as OpenCodeRuntime from "../opencodeRuntime.ts"; -import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; -import { - checkOpenCodeProviderStatus, - loadOpenCode2Workspace, - makeOpenCode2ModelLoader, - type OpenCode2Model, - type OpenCode2Workspace, - openCode2CommandsToServerProviderSlashCommands, - openCode2SkillsToServerProviderSkills, - openCodeCommandsToServerProviderSlashCommands, -} from "./OpenCodeProvider.ts"; -import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; -import { probeOpenCodeRuntime } from "../opencodeVersionProbe.ts"; -import { - OPENCODE_1_RESPONSES, - OPENCODE_2_RESPONSES, - replayOpenCodeServer, -} from "../testFixtures/opencodeProbeResponses.ts"; -const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); - -const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; - -it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => - Effect.gen(function* () { - const resetsAt = "2026-09-17T12:00:00.000Z"; - const limits = yield* readOpenCodeGoUsageLimits({ - enabled: true, - serverUrl: "", - environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" }, - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: (path) => { - NodeAssert.equal(path, "/instance/data/opencode/auth.json"); - return Effect.succeed( - JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }), - ); - }, - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage"); - NodeAssert.equal(request.headers.authorization, "Bearer instance-key"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ - usage: { - rolling: { percent: 0, resetsAt }, - weekly: { percent: 10, resetsAt }, - monthly: { percent: 125, resetsAt }, - }, - }), - ), - ); - }), - ), - Effect.provide(NodeServices.layer), - ); - NodeAssert.equal(limits.unavailable, undefined); - NodeAssert.equal( - limits.credentialFingerprint, - NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"), - ); - NodeAssert.deepEqual( - limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ - kind, - usedPercent, - reset, - })), - [ - { kind: "session", usedPercent: 0, reset: resetsAt }, - { kind: "weekly", usedPercent: 10, reset: resetsAt }, - { kind: "monthly", usedPercent: 100, reset: resetsAt }, - ], - ); - }), -); - -it.effect("does not read local credentials for external or disabled OpenCode instances", () => - Effect.gen(function* () { - for (const settings of [ - { enabled: true, serverUrl: "https://remote.example" }, - { enabled: false, serverUrl: "" }, - ]) { - const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("unexpected credential read"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected usage request")), - ), - Effect.provide(NodeServices.layer), - ); - NodeAssert.equal(limits.unavailable?.reason, "unsupported"); - } - }), -); - -it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () => - Effect.gen(function* () { - for (const [status, reason] of [ - [403, "unsupported"], - [401, "probeFailed"], - [200, "probeFailed"], - ] as const) { - const limits = yield* readOpenCodeGoUsageLimits({ - enabled: true, - serverUrl: "", - environment: { - OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}', - }, - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("inline credentials must bypass disk"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))), - ), - ), - Effect.provide(NodeServices.layer), - ); - NodeAssert.equal(limits.unavailable?.reason, reason); - NodeAssert.equal(limits.credentialFingerprint, undefined); - NodeAssert.deepEqual(limits.windows, []); - } - }), -); - -/** - * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag - * are deleted. The snapshot-producing logic they wrapped now lives in the - * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which - * drivers call directly when building their per-instance snapshot - * `ServerProviderShape`. Tests mirror that shape: build a settings payload, - * invoke the check, assert on the returned snapshot. - */ - -const runtimeMock = { - state: { - runVersionError: null as Error | null, - runVersionPending: false, - versionStdout: DEFAULT_VERSION_STDOUT, - inventoryError: null as Error | null, - connectionError: null as Error | null, - inventoryCwd: null as string | null, - closeCalls: 0, - sdkClientInputs: [] as Array<{ - baseUrl: string; - directory: string; - serverPassword?: string; - }>, - inventory: { - providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, - agents: [] as unknown[], - skills: [] as unknown[], - } as unknown, - }, - reset() { - this.state.runVersionError = null; - this.state.runVersionPending = false; - this.state.versionStdout = DEFAULT_VERSION_STDOUT; - this.state.inventoryError = null; - this.state.connectionError = null; - this.state.inventoryCwd = null; - this.state.closeCalls = 0; - this.state.sdkClientInputs.length = 0; - this.state.inventory = { - providerList: { connected: [], all: [] as unknown[], default: {} }, - agents: [] as unknown[], - skills: [] as unknown[], - }; - }, -}; - -const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ serverPassword, environment }) => - Effect.gen(function* () { - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls += 1; - }), - ); - const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ - external: false, - ...(serverPassword !== undefined ? { serverPassword } : {}), - ...(environment !== undefined ? { environment } : {}), - }); - return { - url: "http://127.0.0.1:4301", - ...(effectiveServerPassword !== undefined - ? { serverPassword: effectiveServerPassword } - : {}), - version: "1.14.19", - isRunning: Effect.succeed(true), - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: ({ serverUrl, serverPassword }) => - Effect.gen(function* () { - if (runtimeMock.state.connectionError) { - return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "global.health", - detail: runtimeMock.state.connectionError.message, - cause: runtimeMock.state.connectionError, - }); - } - if (!serverUrl) { - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls += 1; - }), - ); - } - return { - url: serverUrl ?? "http://127.0.0.1:4301", - ...(serverPassword ? { serverPassword } : {}), - version: "1.14.19", - exitCode: null, - external: Boolean(serverUrl), - }; - }), - runOpenCodeCommand: () => - runtimeMock.state.runVersionPending - ? Effect.never - : runtimeMock.state.runVersionError - ? Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "runOpenCodeCommand", - detail: runtimeMock.state.runVersionError.message, - cause: runtimeMock.state.runVersionError, - }), - ) - : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), - createOpenCodeSdkClient: (input) => { - runtimeMock.state.sdkClientInputs.push(input); - return {} as unknown as ReturnType< - OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"] - >; - }, - loadOpenCodeInventory: () => - runtimeMock.state.inventoryError - ? Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: runtimeMock.state.inventoryError.message, - cause: runtimeMock.state.inventoryError, - }), - ) - : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory), - loadInventoryFromCli: ({ cwd }) => { - runtimeMock.state.inventoryCwd = cwd; - return runtimeMock.state.inventoryError - ? Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: runtimeMock.state.inventoryError.message, - cause: runtimeMock.state.inventoryError, - }), - ) - : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory); - }, - loadOpenCodeSkills: () => Effect.succeed([]), - loadSkillsFromCli: () => Effect.succeed([]), -}; - -beforeEach(() => { - runtimeMock.reset(); -}); - -it("keeps native and MCP commands while preserving compaction and separate skills", () => { - NodeAssert.deepEqual( - openCodeCommandsToServerProviderSlashCommands([ - { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] }, - { name: "review", source: "command", hints: [] }, - { name: "compact", source: "command", hints: [] }, - { name: "skill", source: "skill", hints: [] }, - { name: "mcp:search", source: "mcp", hints: ["query"] }, - ]).slice(1), - [ - { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } }, - { name: "mcp:search", input: { hint: "query" } }, - ], - ); -}); - -const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(NodeServices.layer), -); - -const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSettings => - decodeOpenCodeSettings({ - enabled: true, - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", - customModels: [], - ...overrides, - }); - -const checkProvider = Effect.fn("checkProvider")(function* ( - settings: OpenCodeSettings, - cwd = process.cwd(), - environment?: NodeJS.ProcessEnv, - server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword), - openCode2Models: Effect.Effect< - ReadonlyArray, - OpenCodeRuntime.OpenCodeRuntimeError - > = Effect.succeed([]), -) { - return yield* Effect.scoped( - Effect.gen(function* () { - const serverOwner = yield* OpenCodeServerOwner.make({ - binaryPath: settings.binaryPath, - directory: cwd, - ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), - ...(environment ? { environment } : {}), - }); - const probe = probeOpenCodeRuntime(settings, environment).pipe( - Effect.provideService(HttpClient.HttpClient, server), - Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), - ); - return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe( - Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), - ); - }), - ); -}); - -it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { - it.effect("shows a codex-style missing binary message", () => - Effect.gen(function* () { - runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); - const snapshot = yield* checkProvider(makeOpenCodeSettings()); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, false); - NodeAssert.equal( - snapshot.message, - "OpenCode CLI (`opencode`) is not installed or not on PATH.", - ); - }), - ); - - it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => - Effect.gen(function* () { - runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); - const snapshot = yield* checkProvider(makeOpenCodeSettings()); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); - }), - ); - - it.effect("times out a hanging local CLI version probe", () => - Effect.gen(function* () { - runtimeMock.state.runVersionPending = true; - const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); - - yield* Effect.yieldNow; - yield* TestClock.adjust("4 seconds"); - const snapshot = yield* Fiber.join(probeFiber); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal( - snapshot.message, - "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", - ); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => - Effect.gen(function* () { - runtimeMock.state.inventory = { - providerList: { - connected: ["openai"], - all: [ - { - id: "openai", - name: "OpenAI", - models: { - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", - variants: { - none: {}, - low: {}, - medium: {}, - high: {}, - xhigh: {}, - }, - }, - }, - }, - ], - default: {}, - }, - agents: [ - { name: "build", hidden: false, mode: "primary" }, - { name: "plan", hidden: false, mode: "primary" }, - ], - }; - - const snapshot = yield* checkProvider(makeOpenCodeSettings()); - const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); - - NodeAssert.ok(model); - const variantDescriptor = model.capabilities?.optionDescriptors?.find( - (descriptor) => descriptor.id === "variant" && descriptor.type === "select", - ); - NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); - NodeAssert.equal( - variantDescriptor.options.find((option) => option.isDefault === true)?.id, - "medium", - ); - const agentDescriptor = model.capabilities?.optionDescriptors?.find( - (descriptor) => descriptor.id === "agent" && descriptor.type === "select", - ); - NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); - NodeAssert.equal( - agentDescriptor.options.find((option) => option.isDefault === true)?.id, - "build", - ); - }), - ); - - it.effect("includes OpenCode skills in the provider snapshot", () => - Effect.gen(function* () { - runtimeMock.state.inventory = { - providerList: { - connected: ["openai"], - all: [ - { - id: "openai", - name: "OpenAI", - models: { - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", - variants: {}, - }, - }, - }, - ], - default: {}, - }, - agents: [], - skills: [ - { - name: "openclaw-review", - description: "Review OpenClaw workflow changes.", - location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", - }, - { - name: "openclaw-triage", - description: "Triage OpenClaw routing issues.", - location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", - }, - { - name: "missing-location", - description: "This incomplete SDK row should be skipped.", - location: "", - }, - ], - }; - - const snapshot = yield* checkProvider(makeOpenCodeSettings()); - - NodeAssert.deepEqual( - snapshot.skills.map((skill) => ({ - name: skill.name, - path: skill.path, - enabled: skill.enabled, - shortDescription: skill.shortDescription, - })), - [ - { - name: "openclaw-review", - path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", - enabled: true, - shortDescription: "Review OpenClaw workflow changes.", - }, - { - name: "openclaw-triage", - path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", - enabled: true, - shortDescription: "Triage OpenClaw routing issues.", - }, - ], - ); - }), - ); - - it.effect("loads local inventory from a scoped OpenCode server", () => - Effect.gen(function* () { - yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" })); - - NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ - { - baseUrl: "http://127.0.0.1:4301", - directory: process.cwd(), - serverPassword: "secret-password", - }, - ]); - NodeAssert.equal(runtimeMock.state.closeCalls, 1); - NodeAssert.equal(runtimeMock.state.inventoryCwd, null); - }), - ); - - it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () => - Effect.gen(function* () { - runtimeMock.state.versionStdout = "opencode v2.0.18\n"; - const snapshot = yield* checkProvider( - makeOpenCodeSettings(), - process.cwd(), - undefined, - undefined, - Effect.succeed([ - { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] }, - { - providerID: "opencode", - id: "space-bunny-free", - name: "Space Bunny Free", - variants: [{ id: "low" }, { id: "medium" }, { id: "high" }], - }, - ]), - ); - - NodeAssert.equal(snapshot.status, "ready"); - NodeAssert.equal(snapshot.version, "2.0.18"); - NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ - "approval-required", - "auto-accept-edits", - "auto", - "full-access", - ]); - // Plan mode runs as OpenCode's plan agent, and every session can compact. - NodeAssert.equal(snapshot.showInteractionModeToggle, true); - NodeAssert.deepEqual( - snapshot.slashCommands.map((command) => command.name), - ["compact"], - ); - NodeAssert.deepEqual( - snapshot.models.map((model) => model.slug), - ["opencode/big-pickle", "opencode/space-bunny-free"], - ); - const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free") - ?.capabilities?.optionDescriptors?.[0]; - NodeAssert.deepEqual( - variant?.type === "select" ? variant.options.map((option) => option.id) : [], - ["low", "medium", "high"], - ); - NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); - }), - ); - - it.effect("reports a failed OpenCode 2 model list without the server's response", () => - Effect.gen(function* () { - runtimeMock.state.versionStdout = "opencode v2.0.18\n"; - const snapshot = yield* checkProvider( - makeOpenCodeSettings(), - process.cwd(), - undefined, - undefined, - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "model.list", - detail: 'status=500 body={"token":"leaked-response-body"}', - }), - ), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.message, "OpenCode could not load its model list."); - // A failed catalog read still leaves the session able to compact. - NodeAssert.deepEqual( - snapshot.slashCommands.map((command) => command.name), - ["compact"], - ); - }), - ); - - it.effect("uses an environment-only password for local inventory", () => - Effect.gen(function* () { - yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { - OPENCODE_SERVER_PASSWORD: "environment-password", - }); - - NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ - { - baseUrl: "http://127.0.0.1:4301", - directory: process.cwd(), - serverPassword: "environment-password", - }, - ]); - }), - ); - - it.effect("uses the settings password when local environment auth differs", () => - Effect.gen(function* () { - yield* checkProvider( - makeOpenCodeSettings({ serverPassword: "settings-password" }), - process.cwd(), - { OPENCODE_SERVER_PASSWORD: "environment-password" }, - ); - - NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password"); - }), - ); - - it.effect("reports local model inventory failures without treating them as empty", () => - Effect.gen(function* () { - runtimeMock.state.inventoryError = new Error("opencode models failed"); - const snapshot = yield* checkProvider(makeOpenCodeSettings()); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal(snapshot.models.length, 0); - NodeAssert.equal( - snapshot.message, - "Failed to load OpenCode provider inventory: opencode models failed", - ); - }), - ); -}); - -it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => { - it.effect("does not send a local environment password to a configured server", () => - Effect.gen(function* () { - const snapshot = yield* checkProvider( - makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), - process.cwd(), - { OPENCODE_SERVER_PASSWORD: "local-secret" }, - ); - - NodeAssert.equal(snapshot.version, "1.14.19"); - NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ - { - baseUrl: "http://127.0.0.1:9999", - directory: process.cwd(), - }, - ]); - }), - ); - - it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () => - Effect.gen(function* () { - const settings = makeOpenCodeSettings({ - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }); - const snapshot = yield* checkProvider( - settings, - process.cwd(), - undefined, - replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), - ); - - NodeAssert.equal(snapshot.status, "warning"); - NodeAssert.equal(snapshot.version, "2.0.18"); - NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ - "approval-required", - "auto-accept-edits", - "auto", - "full-access", - ]); - NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); - }), - ); - - it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => - Effect.gen(function* () { - const snapshot = yield* checkProvider( - makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }), - process.cwd(), - undefined, - replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal( - snapshot.message, - "OpenCode server rejected authentication. Check the server URL and password.", - ); - }), - ); - - it.effect("rejects an unsupported server before loading inventory", () => - Effect.gen(function* () { - runtimeMock.state.connectionError = new Error( - "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", - ); - const snapshot = yield* checkProvider( - makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.models.length, 0); - NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/); - NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); - }), - ); - - it.effect("surfaces a friendly auth error for configured servers", () => - Effect.gen(function* () { - runtimeMock.state.connectionError = new Error("401 Unauthorized"); - const snapshot = yield* checkProvider( - makeOpenCodeSettings({ - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal( - snapshot.message, - "OpenCode server rejected authentication. Check the server URL and password.", - ); - }), - ); - - it.effect("surfaces a friendly connection error for configured servers", () => - Effect.gen(function* () { - runtimeMock.state.connectionError = new Error( - "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", - ); - const snapshot = yield* checkProvider( - makeOpenCodeSettings({ - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal( - 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.", - ); - }), - ); -}); - -const bigPickle: OpenCode2Model = { - providerID: "opencode", - id: "big-pickle", - name: "Big Pickle", - variants: [], -}; - -it.effect("waits for a fresh OpenCode 2 server to list its models", () => - Effect.gen(function* () { - // A fresh server lists nothing until its catalog loads. - const replies: Array> = [[], [], [bigPickle]]; - const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle])); - const fiber = yield* load.pipe(Effect.forkChild); - yield* TestClock.adjust("1 second"); - NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); - }).pipe(Effect.provide(TestClock.layer())), -); - -it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () => - Effect.gen(function* () { - let listed: ReadonlyArray = [bigPickle]; - const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed)); - NodeAssert.deepEqual(yield* load, [bigPickle]); - listed = []; - const fiber = yield* load.pipe(Effect.forkChild); - yield* TestClock.adjust("6 seconds"); - NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); - }).pipe(Effect.provide(TestClock.layer())), -); - -// What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29). -const builtinCommands = [ - { name: "init", description: "guided AGENTS.md setup" }, - { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" }, -]; -const builtinSkills = [ - { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" }, - { id: "report", name: "Report", path: "/builtin/report.md" }, -]; -const scanned: OpenCode2Workspace = { - commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }], - skills: [ - ...builtinSkills, - { - id: "greet", - name: "greet", - path: "/work/.opencode/skills/greet/SKILL.md", - description: "Greets the user with the secret word MANGO.", - }, - ], -}; - -it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () => - Effect.gen(function* () { - // A directory the server has not served yet lists no commands until its scan ends. - const reads: Array = [{ commands: [], skills: [] }, scanned]; - const scanEnded = yield* Deferred.make(); - const load = yield* loadOpenCode2Workspace( - Effect.sync(() => reads.shift()!), - Deferred.await(scanEnded), - ).pipe(Effect.forkChild); - yield* Effect.yieldNow; - NodeAssert.equal(reads.length, 1); - yield* Deferred.succeed(scanEnded, undefined); - NodeAssert.deepEqual(yield* Fiber.join(load), scanned); - }), -); - -it.effect("takes a served OpenCode 2 directory's first listing as is", () => - Effect.gen(function* () { - const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never); - NodeAssert.deepEqual(workspace, scanned); - }), -); - -it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => { - NodeAssert.deepEqual( - openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [ - skill.name, - skill.displayName, - skill.path, - ]), - [ - ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"], - ["opencode", "OpenCode", "/builtin/opencode.md"], - ["report", "Report", "/builtin/report.md"], - ], - ); - NodeAssert.deepEqual( - openCode2CommandsToServerProviderSlashCommands([ - ...scanned.commands, - { name: "compact", description: "a workspace command named like T3's own" }, - ]).map((command) => command.name), - ["compact", "init", "review", "hello"], - ); -}); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts deleted file mode 100644 index 96d8bf41bcd79e3cbbbd6ed8f49554691fd06160..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ /dev/null @@ -1,747 +0,0 @@ -import { - type ModelCapabilities, - type OpenCodeSettings, - type ServerProviderModel, - type ServerProviderSkill, - type ServerProviderSlashCommand, -} 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 Schedule from "effect/Schedule"; - -import { createModelCapabilities } from "@t3tools/shared/model"; -import { compareSemverVersions } from "@t3tools/shared/semver"; -import { - buildServerProvider, - COMPACT_SLASH_COMMAND, - nonEmptyTrimmed, - providerModelsFromSettings, - type ServerProviderDraft, -} from "../providerSnapshot.ts"; -import * as OpenCodeRuntime from "../opencodeRuntime.ts"; -import type { ProbedOpenCode } from "../opencodeVersionProbe.ts"; -import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; -import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; - -const OPENCODE_PRESENTATION = { - displayName: "OpenCode", - showInteractionModeToggle: false, -} as const; - -class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ - readonly cause?: unknown; - readonly detail: string; -}> {} - -function normalizeProbeMessage(message: string): string | undefined { - const trimmed = message.trim(); - if (trimmed.length === 0) { - return undefined; - } - if ( - trimmed === "An error occurred in Effect.tryPromise" || - trimmed === "An error occurred in Effect.try" - ) { - return undefined; - } - return trimmed; -} - -function normalizedErrorMessage(cause: unknown): string | undefined { - if (cause instanceof OpenCodeProbeError) { - return normalizeProbeMessage(cause.detail); - } - - if (!(cause instanceof Error)) { - return undefined; - } - - return normalizeProbeMessage(cause.message); -} - -function formatOpenCodeProbeError(input: { - readonly cause: unknown; - readonly isExternalServer: boolean; - readonly phase: "version" | "inventory"; - readonly serverUrl: string; -}): { readonly installed: boolean; readonly message: string } { - const detail = normalizedErrorMessage(input.cause); - const lower = detail?.toLowerCase() ?? ""; - - if (input.isExternalServer) { - if ( - lower.includes("401") || - lower.includes("403") || - lower.includes("unauthorized") || - lower.includes("forbidden") - ) { - return { - installed: true, - message: "OpenCode server rejected authentication. Check the server URL and password.", - }; - } - - if ( - lower.includes("econnrefused") || - lower.includes("enotfound") || - lower.includes("fetch failed") || - lower.includes("networkerror") || - lower.includes("timed out") || - lower.includes("timeout") || - lower.includes("socket hang up") - ) { - 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.`, - }; - } - - return { - installed: true, - message: detail ?? "Failed to connect to the configured OpenCode server.", - }; - } - - if (lower.includes("enoent") || lower.includes("notfound")) { - return { - installed: false, - message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", - }; - } - - if (lower.includes("quarantine")) { - return { - installed: true, - message: - "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", - }; - } - - if (lower.includes("invalid code signature") || lower.includes("corrupted")) { - return { - installed: true, - message: - "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", - }; - } - - const failureLabel = - input.phase === "inventory" - ? "Failed to load OpenCode provider inventory" - : "Failed to execute OpenCode CLI health check"; - return { - installed: true, - message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, - }; -} - -function titleCaseSlug(value: string): string { - const segments: Array = []; - for (const segment of value.split(/[-_/]+/)) { - if (segment.length > 0) { - segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); - } - } - return segments.join(" "); -} - -function inferDefaultVariant( - providerID: string, - variants: ReadonlyArray, -): string | undefined { - if (variants.length === 1) { - return variants[0]; - } - if (providerID === "anthropic" || providerID.startsWith("google")) { - return variants.includes("high") ? "high" : undefined; - } - if (providerID === "openai" || providerID === "opencode") { - return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; - } - return undefined; -} - -function inferDefaultAgent(agents: ReadonlyArray): string | undefined { - return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; -} - -const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [ - { - id: "variant", - label: "Reasoning", - type: "select", - options: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium", isDefault: true }, - { id: "high", label: "High" }, - { id: "xhigh", label: "Extra High" }, - ], - currentValue: "medium", - }, - { - id: "agent", - label: "Agent", - type: "select", - options: [ - { id: "build", label: "Build", isDefault: true }, - { id: "plan", label: "Plan" }, - ], - currentValue: "build", - }, - ], -}); - -function openCodeCapabilitiesForModel(input: { - readonly providerID: string; - readonly model: ProviderListResponse["all"][number]["models"][string]; - readonly agents: ReadonlyArray; -}): ModelCapabilities { - const rawVariantValues = Object.keys(input.model.variants ?? {}); - // When a model advertises no variants, synthesize the standard reasoning - // levels so the composer still offers a Reasoning selector (mirrors the - // Codex/Grok experience where reasoning is always configurable). The set - // covers the common OpenCode variant spectrum; `inferDefaultVariant` - // picks the provider-appropriate default (e.g. medium for openai/opencode). - const variantValues = - rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; - const defaultVariant = inferDefaultVariant(input.providerID, variantValues); - const variantOptions = variantValues.map((value) => - defaultVariant === value - ? { id: value, label: titleCaseSlug(value), isDefault: true as const } - : { id: value, label: titleCaseSlug(value) }, - ); - const primaryAgents = input.agents.filter( - (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), - ); - const defaultAgent = inferDefaultAgent(primaryAgents); - const agentOptions = primaryAgents.map((agent) => - defaultAgent === agent.name - ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } - : { id: agent.name, label: titleCaseSlug(agent.name) }, - ); - return createModelCapabilities({ - optionDescriptors: [ - ...(variantOptions.length > 0 - ? [ - { - id: "variant", - label: "Reasoning", - type: "select" as const, - options: variantOptions, - ...(defaultVariant ? { currentValue: defaultVariant } : {}), - }, - ] - : []), - ...(agentOptions.length > 0 - ? [ - { - id: "agent", - label: "Agent", - type: "select" as const, - options: agentOptions, - ...(defaultAgent ? { currentValue: defaultAgent } : {}), - }, - ] - : []), - ], - }); -} - -function flattenOpenCodeModels( - input: OpenCodeRuntime.OpenCodeInventory, -): ReadonlyArray { - const connected = new Set(input.providerList.connected); - const models: Array = []; - - for (const provider of input.providerList.all) { - if (!connected.has(provider.id)) { - continue; - } - - for (const model of Object.values(provider.models)) { - const name = nonEmptyTrimmed(model.name); - if (!name) { - continue; - } - - const subProvider = nonEmptyTrimmed(provider.name); - models.push({ - slug: `${provider.id}/${model.id}`, - name, - ...(subProvider ? { subProvider } : {}), - isCustom: false, - capabilities: openCodeCapabilitiesForModel({ - providerID: provider.id, - model, - agents: input.agents, - }), - }); - } - } - - return models.toSorted((left, right) => left.name.localeCompare(right.name)); -} - -function trimOptional(value: string | null | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; -} - -export function openCodeSkillsToServerProviderSkills( - input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined, -): ReadonlyArray { - const skills: ServerProviderSkill[] = []; - for (const skill of input ?? []) { - const name = trimOptional(skill.name); - const path = trimOptional(skill.location); - if (!name || !path) { - continue; - } - - const description = trimOptional(skill.description); - skills.push({ - name, - path, - enabled: true, - ...(description ? { description, shortDescription: description } : {}), - }); - } - - return skills.toSorted((left, right) => left.name.localeCompare(right.name)); -} - -export function openCodeCommandsToServerProviderSlashCommands( - input: OpenCodeRuntime.OpenCodeInventory["commands"], -): ReadonlyArray { - const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; - const names = new Set([COMPACT_SLASH_COMMAND.name]); - for (const command of input ?? []) { - const name = trimOptional(command.name); - if (!name || names.has(name) || command.source === "skill") continue; - names.add(name); - const description = trimOptional(command.description); - const hint = trimOptional(command.hints.join(" ")); - commands.push({ - name, - ...(description ? { description } : {}), - ...(hint ? { input: { hint } } : {}), - }); - } - return commands; -} - -export const makePendingOpenCodeProvider = ( - openCodeSettings: OpenCodeSettings, -): Effect.Effect => - Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = providerModelsFromSettings( - [], - openCodeSettings.customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - - if (!openCodeSettings.enabled) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: - openCodeSettings.serverUrl.trim().length > 0 - ? "OpenCode is disabled in T3 Code settings. A server URL is configured." - : "OpenCode is disabled in T3 Code settings.", - }, - }); - } - - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: true, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "OpenCode provider status has not been checked in this session yet.", - }, - }); - }); - -/** One model an OpenCode 2 server lists, as the status check reads it. */ -export interface OpenCode2Model { - readonly providerID: string; - readonly id: string; - readonly name: string; - readonly variants: ReadonlyArray<{ readonly id: string }>; -} - -/** - * A server loads its catalog lazily and lists nothing for its first few - * hundred milliseconds, and a local instance starts a fresh server for each - * status check. So an empty list is read again for a while, and the last - * non-empty list stands in if the catalog still has not loaded. - */ -export const makeOpenCode2ModelLoader = ( - list: Effect.Effect, E>, -) => - Effect.sync(() => { - let lastLoaded: ReadonlyArray = []; - return list.pipe( - Effect.repeat({ - until: (models) => models.length > 0, - schedule: Schedule.spaced("250 millis"), - }), - Effect.timeoutOption("5 seconds"), - Effect.map((loaded) => { - if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value; - return lastLoaded; - }), - ); - }); - -/** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ -export interface OpenCode2Workspace { - readonly skills: ReadonlyArray<{ - readonly id: string; - readonly name: string; - readonly path: string; - readonly description?: string | undefined; - }>; - readonly commands: ReadonlyArray<{ - readonly name: string; - readonly description?: string | undefined; - }>; -} - -/** - * A server scans a directory it has not served yet in the background: its - * first read lists no commands at all (not even the built-ins), and - * `command.updated` plus `skill.updated` for the directory end the scan. A - * directory it already serves lists everything at once. `scanned` must be - * listening before `read` runs, since the read is what starts the scan. - */ -export const loadOpenCode2Workspace = ( - read: Effect.Effect, - scanned: Effect.Effect, -) => - Effect.gen(function* () { - const first = yield* read; - if (first.commands.length > 0) return first; - yield* scanned.pipe(Effect.timeoutOption("10 seconds")); - return yield* read; - }); - -export function openCode2SkillsToServerProviderSkills( - skills: OpenCode2Workspace["skills"], -): ReadonlyArray { - return skills - .map((skill) => { - const description = trimOptional(skill.description); - return { - name: skill.id, - path: skill.path, - enabled: true, - ...(skill.name === skill.id ? {} : { displayName: skill.name }), - ...(description ? { description, shortDescription: description } : {}), - }; - }) - .toSorted((left, right) => left.name.localeCompare(right.name)); -} - -export function openCode2CommandsToServerProviderSlashCommands( - commands: OpenCode2Workspace["commands"], -): ReadonlyArray { - const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; - const names = new Set([COMPACT_SLASH_COMMAND.name]); - for (const command of commands) { - const name = trimOptional(command.name); - if (!name || names.has(name)) continue; - names.add(name); - const description = trimOptional(command.description); - slashCommands.push({ name, ...(description ? { description } : {}) }); - } - return slashCommands; -} - -/** - * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: - * OpenCode has no reviewer that approves routine actions. Plan mode is its - * `plan` agent, so the composer's mode toggle drives it. - */ -const OPENCODE_2_PRESENTATION = { - ...OPENCODE_PRESENTATION, - showInteractionModeToggle: true, - supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], -} as const; - -function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { - const variants = model.variants.map((variant) => variant.id); - const defaultVariant = inferDefaultVariant(model.providerID, variants); - return createModelCapabilities({ - optionDescriptors: - variants.length === 0 - ? [] - : [ - { - id: "variant", - label: "Reasoning", - type: "select", - options: variants.map((id) => ({ - id, - label: titleCaseSlug(id), - ...(id === defaultVariant ? { isDefault: true } : {}), - })), - ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), - }, - ], - }); -} - -const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( - settings: OpenCodeSettings, - version: string, - checkedAt: string, - loadModels: Effect.Effect, OpenCodeRuntime.OpenCodeRuntimeError>, -) { - const result = yield* Effect.exit(loadModels); - const probe = (status: "ready" | "warning" | "error", message: string) => ({ - installed: true, - version, - status, - auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) }, - message, - }); - if (result._tag === "Failure") { - // The detail can carry a response body; it stays in the log, not the status. - yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); - return buildServerProvider({ - presentation: OPENCODE_2_PRESENTATION, - enabled: true, - checkedAt, - models: providerModelsFromSettings( - [], - settings.customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), - // Compaction does not depend on the model catalog. - slashCommands: [COMPACT_SLASH_COMMAND], - probe: probe("error", "OpenCode could not load its model list."), - }); - } - const models = providerModelsFromSettings( - result.value - .map((model) => ({ - slug: `${model.providerID}/${model.id}`, - name: model.name, - isCustom: false, - capabilities: openCode2ModelCapabilities(model), - })) - .toSorted((left, right) => left.name.localeCompare(right.name)), - settings.customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - return buildServerProvider({ - presentation: OPENCODE_2_PRESENTATION, - enabled: true, - checkedAt, - models, - // Every session can compact; a workspace snapshot adds that directory's commands. - slashCommands: [COMPACT_SLASH_COMMAND], - probe: - result.value.length > 0 - ? probe( - "ready", - `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`, - ) - : probe("warning", "OpenCode 2 is running, but it did not list any models yet."), - }); -}); - -/** - * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary, - * the version endpoints for a configured server. The status check refreshes it, so an in-place - * upgrade re-routes the instance. - */ -export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( - openCodeSettings: OpenCodeSettings, - cwd: string, - probeRuntime: Effect.Effect, - loadOpenCode2Models: Effect.Effect< - ReadonlyArray, - OpenCodeRuntime.OpenCodeRuntimeError - >, -): Effect.fn.Return< - ServerProviderDraft, - never, - OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner -> { - const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; - 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, - phase: "version" | "inventory" = "version", - ) => { - const failure = formatOpenCodeProbeError({ - cause, - isExternalServer, - phase, - serverUrl: openCodeSettings.serverUrl, - }); - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: openCodeSettings.enabled, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: failure.installed, - version, - status: "error", - auth: { status: "unknown" }, - message: failure.message, - }, - }); - }; - - if (!openCodeSettings.enabled) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: false, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: isExternalServer - ? "OpenCode is disabled in T3 Code settings. A server URL is configured." - : "OpenCode is disabled in T3 Code settings.", - }, - }); - } - - const probedExit = yield* Effect.exit( - probeRuntime.pipe( - Effect.mapError( - (cause) => - new OpenCodeProbeError({ - cause, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - }), - ), - ), - ); - if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); - const probed = probedExit.value; - if (probed.generation === "v2") { - return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); - } - let version: string | null = probed.version; - if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: openCodeSettings.enabled, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: true, - version: probed.version, - status: "error", - auth: { status: "unknown" }, - message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, - }, - }); - } - - const loadInventory = (server: { - readonly url: string; - readonly serverPassword?: string; - readonly version: string; - }) => - openCodeRuntime - .loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), - }), - ) - .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); - const inventoryEffect = isExternalServer - ? openCodeRuntime - .connectToOpenCodeServer({ - binaryPath: openCodeSettings.binaryPath, - directory: cwd, - serverUrl: openCodeSettings.serverUrl, - ...(openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }) - .pipe(Effect.flatMap(loadInventory), Effect.scoped) - : serverOwner.withServer(loadInventory); - const inventoryExit = yield* Effect.exit( - inventoryEffect.pipe( - Effect.mapError( - (cause) => - new OpenCodeProbeError({ - cause, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - }), - ), - ), - ); - if (inventoryExit._tag === "Failure") { - return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); - } - - version = inventoryExit.value.version; - - const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value.inventory), - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); - const connectedCount = inventoryExit.value.inventory.providerList.connected.length; - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: true, - checkedAt, - models, - skills, - slashCommands: openCodeCommandsToServerProviderSlashCommands( - inventoryExit.value.inventory.commands, - ), - probe: { - installed: true, - version, - status: connectedCount > 0 ? "ready" : "warning", - auth: { - status: connectedCount > 0 ? "authenticated" : "unknown", - type: "opencode", - }, - message: - connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` - : isExternalServer - ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." - : "OpenCode is available, but it did not report any connected upstream providers.", - }, - }); -}); diff --git a/apps/server/src/provider/Layers/PiProvider.test.ts b/apps/server/src/provider/Layers/PiProvider.test.ts deleted file mode 100644 index 7ca54dbf3cb0a279003558792f8d6e570ababc4d..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/PiProvider.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; - -import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./PiProvider.ts"; - -const encoder = new TextEncoder(); - -function processHandle(input: { - readonly stdout?: string; - readonly stderr?: string; - readonly exitCode?: number; -}) { - const bytes = (value: string | undefined) => - value === undefined || value.length === 0 - ? Stream.empty - : Stream.succeed(encoder.encode(value)); - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(900_000_001), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: bytes(input.stdout), - stderr: bytes(input.stderr), - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); -} - -function piProbeSpawner(version: string) { - return ChildProcessSpawner.make((command) => { - const args = ChildProcess.isStandardCommand(command) ? command.args : []; - return Effect.succeed( - args.includes("--version") - ? processHandle({ stdout: `pi ${version}\n` }) - : processHandle({ stderr: "RPC startup failed", exitCode: 1 }), - ); - }); -} - -const settings = { - enabled: true, - binaryPath: "pi", - launchArgs: "", - customModels: [], -} as const; - -describe("PiProvider", () => { - it.effect("requires the first published Pi version with entries and settlement hooks", () => - Effect.gen(function* () { - const snapshot = yield* checkPiProviderStatus(settings).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.80.3")), - ); - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.version, "0.80.3"); - assert.include(snapshot.message ?? "", `Pi ${MINIMUM_PI_VERSION} or newer`); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("keeps compatible Pi selectable when optional discovery fails", () => - Effect.gen(function* () { - const snapshot = yield* checkPiProviderStatus(settings).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.84.3")), - ); - assert.equal(snapshot.status, "ready"); - assert.equal(snapshot.auth.status, "unknown"); - assert.deepEqual( - snapshot.models.map((model) => model.slug), - ["default"], - ); - assert.include(snapshot.message ?? "", "could not refresh its models and commands"); - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts deleted file mode 100644 index 1a576c4fe2c3d46c0bc6fdee442c47325f9aa7a6..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ /dev/null @@ -1,441 +0,0 @@ -/** - * PiProvider — snapshot/probe layer for the Pi coding agent. - * - * Health is probed with `pi --version`. Models, the user's default model, and - * the user's commands (extension slash commands, prompt templates, skills) - * are discovered through a short-lived ephemeral RPC session - * (`pi --mode rpc --no-session`), so everything the user configured in - * `~/.pi/agent` — custom providers, models.json entries, extensions, skills — - * shows up in T3 without any hardcoded catalog. - */ -import { - type CustomModelSetting, - type PiSettings, - type ServerProvider, - type ServerProviderModel, -} from "@t3tools/contracts"; -import { causeErrorTag } from "@t3tools/shared/observability"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { compareSemverVersions } from "@t3tools/shared/semver"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Stream from "effect/Stream"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import { HttpClient } from "effect/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; - -import { - buildPiRpcLaunch, - resolvePiLaunchArgs, -} from "../../orchestration-v2/Adapters/piT3McpInjection.ts"; -import { - makePiRpcConnection, - piRecordField as recordField, - piRecordString as recordString, -} from "../../orchestration-v2/Adapters/PiRpc.ts"; -import { - buildServerProvider, - isCommandMissingCause, - parseGenericCliVersion, - providerModelsFromSettings, - spawnAndCollect, - type ServerProviderDraft, -} from "../providerSnapshot.ts"; -import { - enrichProviderSnapshotWithVersionAdvisory, - type ProviderMaintenanceCapabilities, -} from "../providerMaintenance.ts"; -import { - EMPTY_PI_MODEL_CAPABILITIES, - thinkingCapabilitiesForPiModel, -} from "./piThinkingCapabilities.ts"; -import { - parsePiDiscoveredCommands, - withPiBuiltinSlashCommands, - type PiDiscoveredCommands, -} from "../PiCommands.ts"; - -const PI_PRESENTATION = { - displayName: "Pi", - showInteractionModeToggle: false, - supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], - // The adapter reports context usage from Pi's streaming usage while a - // turn runs, so clients can reserve the meter before the first settle. - reportsContextWindow: true, - requiresNewThreadForModelChange: false, -} as const; - -const VERSION_PROBE_TIMEOUT_MS = 4_000; -const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000; -/** - * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. - * Version 0.80.5 was the first published package containing both hooks. T3 - * needs them for rollback boundaries and reliable turn terminalization. - */ -export const MINIMUM_PI_VERSION = "0.80.5"; - -/** Deferring to the user's own settings.json default model. */ -const PI_DEFAULT_MODEL: ServerProviderModel = { - slug: "default", - name: "Pi default", - isCustom: false, - capabilities: EMPTY_PI_MODEL_CAPABILITIES, -}; - -interface PiDiscovery extends PiDiscoveredCommands { - readonly models: ReadonlyArray; - readonly authenticated: boolean; -} - -function piModelsFromSettings( - customModels: ReadonlyArray | undefined, - discovered: ReadonlyArray = [], -): ReadonlyArray { - return providerModelsFromSettings( - [PI_DEFAULT_MODEL, ...discovered], - customModels ?? [], - EMPTY_PI_MODEL_CAPABILITIES, - ); -} - -function parseDiscoveredModels( - data: unknown, - defaultThinkingLevel: unknown, -): ReadonlyArray { - const models = recordField(data, "models"); - if (!Array.isArray(models)) return []; - const seen = new Set(); - const parsed: Array = []; - for (const model of models) { - const provider = recordString(model, "provider"); - const id = recordString(model, "id"); - if (provider === undefined || id === undefined) continue; - const slug = `${provider}/${id}`; - if (seen.has(slug)) continue; - seen.add(slug); - parsed.push({ - slug, - name: recordString(model, "name") ?? slug, - isCustom: false, - capabilities: thinkingCapabilitiesForPiModel(model, defaultThinkingLevel), - }); - } - return parsed; -} - -const discoverPiViaRpc = ( - piSettings: PiSettings, - environment: NodeJS.ProcessEnv, - launchArgs: ReadonlyArray, - cwd?: string, -) => - Effect.gen(function* () { - const launch = buildPiRpcLaunch({ - launchArgs, - environment, - mcpSession: undefined, - extensionPath: undefined, - ephemeral: true, - }); - const connection = yield* makePiRpcConnection({ - command: piSettings.binaryPath || "pi", - args: launch.args, - cwd, - env: launch.env, - }); - yield* Stream.fromQueue(connection.events).pipe( - Stream.runDrain, - Effect.ignore, - Effect.forkScoped, - ); - const stateData = yield* connection.request({ type: "get_state" }); - const modelsData = yield* connection.request({ type: "get_available_models" }); - const commandsData = yield* connection - .request({ type: "get_commands" }) - .pipe(Effect.orElseSucceed(() => undefined)); - const discoveredModels = parseDiscoveredModels( - modelsData, - recordString(stateData, "thinkingLevel"), - ); - const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); - return { - models: discoveredModels, - slashCommands: withPiBuiltinSlashCommands(slashCommands), - skills, - authenticated: discoveredModels.length > 0, - } satisfies PiDiscovery; - }).pipe(Effect.scoped); - -const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => - Effect.gen(function* () { - const command = piSettings.binaryPath || "pi"; - const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { - env: environment, - }); - return yield* spawnAndCollect( - command, - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - env: environment, - shell: spawnCommand.shell, - }), - ); - }); - -export function buildInitialPiProviderSnapshot( - piSettings: PiSettings, -): Effect.Effect { - return Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = piModelsFromSettings(piSettings.customModels); - if (!piSettings.enabled) { - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Pi is disabled in T3 Code settings.", - }, - }); - } - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: true, - checkedAt, - models, - probe: { - installed: true, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Checking Pi CLI availability...", - }, - }); - }); -} - -export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( - piSettings: PiSettings, - environment: NodeJS.ProcessEnv = process.env, - cwd?: string, -): Effect.fn.Return { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const fallbackModels = piModelsFromSettings(piSettings.customModels); - - if (!piSettings.enabled) { - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: false, - checkedAt, - models: fallbackModels, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Pi is disabled in T3 Code settings.", - }, - }); - } - - const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( - Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(versionResult)) { - const error = versionResult.failure; - yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag }); - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: piSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: !isCommandMissingCause(error), - version: null, - status: "error", - auth: { status: "unknown" }, - message: isCommandMissingCause(error) - ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g @earendil-works/pi-coding-agent`." - : "Failed to execute Pi CLI health check.", - }, - }); - } - - if (Option.isNone(versionResult.success)) { - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: piSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: "Pi CLI is installed but timed out while running `pi --version`.", - }, - }); - } - - const versionOutput = versionResult.success.value; - const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); - if (versionOutput.code !== 0) { - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: piSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: "Pi CLI is installed but failed to run.", - }, - }); - } - - if (version === null) { - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: piSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: `T3 Code could not determine the Pi version. Pi ${MINIMUM_PI_VERSION} or newer is required.`, - }, - }); - } - - if (compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) { - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: piSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: `Pi ${version} is unsupported. Update to Pi ${MINIMUM_PI_VERSION} or newer.`, - }, - }); - } - - const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); - if (!resolvedLaunchArgs.ok) { - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: piSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: resolvedLaunchArgs.message, - }, - }); - } - - const discoveryExit = yield* discoverPiViaRpc( - piSettings, - environment, - resolvedLaunchArgs.args, - cwd, - ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit); - if (Exit.isFailure(discoveryExit)) { - yield* Effect.logWarning("Pi RPC discovery failed.", { - errorTag: causeErrorTag(discoveryExit.cause), - }); - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: piSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: "ready", - auth: { status: "unknown" }, - message: - "Pi is available, but T3 Code could not refresh its models and commands. The live session will retry startup.", - }, - }); - } - if (Option.isNone(discoveryExit.value)) { - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: piSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: "ready", - auth: { status: "unknown" }, - message: - "Pi is available, but model and command discovery needs interactive input. The live session will handle it.", - }, - }); - } - - const discovery = discoveryExit.value.value; - const models = piModelsFromSettings(piSettings.customModels, discovery.models); - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: piSettings.enabled, - checkedAt, - models, - slashCommands: discovery.slashCommands, - skills: discovery.skills, - probe: { - installed: true, - version, - status: discovery.authenticated ? "ready" : "warning", - auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, - ...(discovery.authenticated - ? {} - : { - message: - "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.", - }), - }, - }); -}); - -export const enrichPiSnapshot = (input: { - readonly snapshot: ServerProvider; - readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; - readonly enableProviderUpdateChecks?: boolean; - readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; - readonly httpClient: HttpClient.HttpClient; -}): Effect.Effect => { - const { snapshot, publishSnapshot } = input; - return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { - enableProviderUpdateChecks: input.enableProviderUpdateChecks, - }).pipe( - Effect.provideService(HttpClient.HttpClient, input.httpClient), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - Effect.catchCause((cause) => - Effect.logWarning("Pi version advisory enrichment failed", { - errorTag: causeErrorTag(cause), - }), - ), - Effect.asVoid, - ); -}; diff --git a/apps/server/src/provider/Layers/ProviderAuthService.test.ts b/apps/server/src/provider/Layers/ProviderAuthService.test.ts deleted file mode 100644 index be4acd6a6c38ab33e5d036301fd61a79268fae8b..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ProviderAuthService.test.ts +++ /dev/null @@ -1,980 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { - ProviderDriverKind, - ProviderInstanceId, - ProviderSessionId, - ProviderSetupError, - ProviderThreadId, - ThreadId, - type OrchestrationV2ProviderSession, - type OrchestrationV2ThreadShell, - type ProviderAuthState, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as PubSub from "effect/PubSub"; -import * as Queue from "effect/Queue"; -import * as Stream from "effect/Stream"; -import * as SubscriptionRef from "effect/SubscriptionRef"; - -import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; -import * as ProviderSessionManager from "../../orchestration-v2/ProviderSessionManager.ts"; -import { AcpProviderCapabilitiesV2 } from "../../orchestration-v2/Adapters/AcpAdapterV2.ts"; -import type { ProviderInstance } from "../ProviderDriver.ts"; -import type { ProviderAuthController } from "../Services/ProviderAuthService.ts"; -import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; -import { makeProviderAuthService } from "./ProviderAuthService.ts"; - -const instanceId = ProviderInstanceId.make("antigravity-personal"); -const otherInstanceId = ProviderInstanceId.make("antigravity-work"); -const unsupportedInstanceId = ProviderInstanceId.make("codex"); -const driverKind = ProviderDriverKind.make("antigravity"); -const owner = "paired-client-owner"; -const otherOwner = "paired-client-other"; -const flowId = "test-sign-in-flow"; -const callbackUrl = "http://127.0.0.1:48123/?state=test-state&code=test-code"; -const now = "2026-09-02T00:00:00.000Z"; -const idleAuthState: ProviderAuthState = { - instanceId, - phase: "idle", - flowId: null, - authorizationUrl: null, - expiresAt: null, - message: null, -}; -const waitingAuthState: ProviderAuthState = { - ...idleAuthState, - phase: "waiting", - flowId, - authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=test-state", - expiresAt: "2026-09-02T00:05:00.000Z", -}; - -function makeInstance(input: { - instanceId: ProviderInstanceId; - enabled: boolean; - auth?: ProviderAuthController; -}): ProviderInstance { - return { - ...input, - driverKind, - displayName: undefined, - continuationIdentity: { driverKind, continuationKey: input.instanceId }, - get snapshot(): never { - throw new Error("Auth routing must not refresh the provider snapshot."); - }, - get orchestrationAdapter(): never { - throw new Error("Auth routing must not start an adapter session."); - }, - get textGeneration(): never { - throw new Error("Auth routing must not generate text."); - }, - }; -} - -const nowUtc = DateTime.makeUnsafe(now); - -/** - * A thread bound to a provider instance with (or without) a live native - * thread. The auth service reads only the routing fields, so the rest of the - * shell row is left out. - */ -function makeThread( - thread: string, - input: { readonly providerInstanceId?: ProviderInstanceId; readonly active?: boolean } = {}, -): OrchestrationV2ThreadShell { - const shell: Pick< - OrchestrationV2ThreadShell, - "id" | "providerInstanceId" | "activeProviderThreadId" - > = { - id: ThreadId.make(thread), - providerInstanceId: input.providerInstanceId ?? instanceId, - activeProviderThreadId: - input.active === false ? null : ProviderThreadId.make(`${thread}:native`), - }; - return shell as unknown as OrchestrationV2ThreadShell; -} - -function makeSession( - session: string, - status: OrchestrationV2ProviderSession["status"] = "ready", - providerInstanceId = instanceId, -): OrchestrationV2ProviderSession { - return { - id: ProviderSessionId.make(session), - driver: driverKind, - providerInstanceId, - status, - cwd: "/workspace", - model: null, - capabilities: AcpProviderCapabilitiesV2, - createdAt: nowUtc, - updatedAt: nowUtc, - lastError: null, - }; -} - -function sessionThreads(list: ReadonlyArray) { - return { - threads: list.map((session) => makeThread(session.id)), - sessions: new Map(list.map((session) => [ThreadId.make(session.id), [session]])), - }; -} - -const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* ( - input: { - enabled?: boolean; - threads?: ReadonlyArray; - sessions?: ReadonlyMap>; - shellError?: boolean; - stopError?: boolean; - logoutError?: ProviderSetupError; - sharedCredentials?: boolean; - sharedBusy?: boolean; - sharedBusyEffect?: Effect.Effect; - responds?: boolean; - beforeLogout?: Effect.Effect; - beforeStop?: Effect.Effect; - beforeListSessions?: Effect.Effect; - onLookup?: Effect.Effect; - onListThreads?: (instances: ProviderInstance[]) => void; - } = {}, -) { - const actions: string[] = []; - const registryChanges = yield* PubSub.unbounded(); - const threads = input.threads ?? []; - const sessions = new Map( - [...(input.sessions?.entries() ?? [])].map(([threadId, list]) => [threadId, [...list]]), - ); - const released: string[] = []; - const idle = idleAuthState; - let state = idle; - let flowOwner: string | undefined; - let gateClosed = false; - - const checkOwner = Effect.fn("ProviderAuthService.test.checkOwner")(function* ( - ownerSessionId: string, - requestedFlowId: string, - operation: string, - ) { - if (ownerSessionId !== flowOwner || requestedFlowId !== state.flowId) { - return yield* new ProviderSetupError({ - instanceId, - operation, - detail: "This sign-in belongs to another client or has expired.", - }); - } - }); - - const auth: ProviderAuthController = { - ...(input.sharedCredentials - ? { credentialBinding: { owner: "provider" as const, key: "shared" } } - : {}), - ...(input.responds - ? { - respond: Effect.fn(function* (ownerSessionId, request) { - yield* checkOwner(ownerSessionId, request.flowId, "respond"); - actions.push(`respond:${request.response.type}`); - return state; - }), - } - : {}), - start: Effect.fn(function* (ownerSessionId, stopSessions) { - gateClosed = true; - actions.push("close-gate"); - yield* stopSessions ?? Effect.void; - flowOwner = ownerSessionId; - state = waitingAuthState; - actions.push("start-sign-in"); - return state; - }), - complete: Effect.fn(function* (ownerSessionId, request) { - yield* checkOwner(ownerSessionId, request.flowId, "complete"); - if (request.callbackUrl !== callbackUrl) { - return yield* new ProviderSetupError({ - instanceId, - operation: "complete", - detail: "The redirect URL does not match this sign-in.", - }); - } - state = { ...idle, flowId, phase: "succeeded" }; - return state; - }), - cancel: Effect.fn(function* (ownerSessionId, requestedFlowId) { - yield* checkOwner(ownerSessionId, requestedFlowId, "cancel"); - state = { ...idle, flowId, phase: "cancelled" }; - return state; - }), - logout: Effect.fn(function* (stopSessions) { - gateClosed = true; - actions.push("close-gate"); - yield* stopSessions; - if (input.logoutError) return yield* input.logoutError; - yield* input.beforeLogout ?? Effect.void; - actions.push("native-logout"); - state = idle; - flowOwner = undefined; - return state; - }), - subscribe: (ownerSessionId) => - Stream.fromEffect(Effect.sync(() => (ownerSessionId === flowOwner ? state : idle))), - isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout", - }; - const instances = [ - makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), - makeInstance({ instanceId: unsupportedInstanceId, enabled: true }), - ...(input.sharedCredentials - ? [ - makeInstance({ - instanceId: otherInstanceId, - enabled: true, - auth: { - ...auth, - isChangingCredentials: - input.sharedBusyEffect ?? Effect.succeed(input.sharedBusy ?? false), - invalidate: Effect.sync(() => { - actions.push("invalidate-shared"); - }), - }, - }), - ] - : []), - ]; - const service = yield* makeProviderAuthService.pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ - getInstance: (id) => - Effect.gen(function* () { - const found = instances.find((instance) => instance.instanceId === id); - yield* input.onLookup ?? Effect.void; - return found; - }), - listInstances: Effect.succeed(instances), - subscribeChanges: PubSub.subscribe(registryChanges), - }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getRecoveryThreadIds: () => - Effect.gen(function* () { - assert.isTrue(gateClosed); - actions.push("list-threads"); - input.onListThreads?.(instances); - yield* input.beforeListSessions ?? Effect.void; - return yield* input.shellError - ? Effect.fail( - new ProjectionStore.ProjectionStoreReadError({ - threadId: ThreadId.make("shell"), - cause: new Error("private database diagnostics"), - }), - ) - : Effect.succeed(threads.map((thread) => thread.id)); - }), - getThreadRecords: (threadId) => - Effect.sync(() => { - actions.push(`sessions:${threadId}`); - return { - providerSessions: sessions.get(threadId) ?? [], - } as never; - }), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ - release: ({ providerSessionId, reason }) => - Effect.gen(function* () { - assert.isTrue(gateClosed); - actions.push(`stop:${providerSessionId}`); - yield* input.beforeStop ?? Effect.void; - if (input.stopError) { - return yield* Effect.fail( - new ProviderSessionManager.ProviderSessionReleaseError({ - providerSessionId, - reason, - cause: new Error("private process diagnostics"), - }), - ); - } - released.push(providerSessionId); - for (const [threadId, list] of sessions) { - const remaining = list.filter((session) => session.id !== providerSessionId); - if (remaining.length === 0) sessions.delete(threadId); - else sessions.set(threadId, remaining); - } - }), - }), - ), - ), - ); - return { - service, - actions, - released, - sessions, - auth, - addInstance: (instance: ProviderInstance) => instances.push(instance), - replaceInstance: (replacement: ProviderInstance) => { - const index = instances.findIndex( - (instance) => instance.instanceId === replacement.instanceId, - ); - assert.isAtLeast(index, 0); - instances[index] = replacement; - }, - replaceAuth: (next: ProviderAuthController) => { - instances[0] = makeInstance({ instanceId, enabled: input.enabled ?? true, auth: next }); - }, - }; -}); - -const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( - function* (flowOwner: string) { - const state = yield* SubscriptionRef.make(idleAuthState); - const close = yield* Deferred.make(); - const closedSubscriptions = yield* Queue.unbounded(); - const unused = () => Effect.die("Unexpected auth operation in a subscription test."); - const auth: ProviderAuthController = { - start: unused, - complete: unused, - cancel: unused, - logout: unused, - subscribe: (ownerSessionId) => - SubscriptionRef.changes(state).pipe( - Stream.map((current) => (ownerSessionId === flowOwner ? current : idleAuthState)), - Stream.interruptWhen(Deferred.await(close)), - Stream.ensuring(Queue.offer(closedSubscriptions, ownerSessionId)), - ), - }; - return { - auth, - state, - closedSubscriptions, - close: Deferred.succeed(close, undefined), - }; - }, -); - -const makeSubscriptionHarness = Effect.fn("ProviderAuthService.test.makeSubscriptionHarness")( - function* (initial: ProviderInstance, replaceDuringFirstLookup?: ProviderInstance) { - const changes = yield* PubSub.unbounded(); - let current: ProviderInstance | undefined = initial; - let pendingReplacement = replaceDuringFirstLookup; - let subscribed = false; - const service = yield* makeProviderAuthService.pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ - subscribeChanges: Effect.gen(function* () { - const subscription = yield* PubSub.subscribe(changes); - subscribed = true; - return subscription; - }), - getInstance: () => - Effect.gen(function* () { - const instance = current; - if (pendingReplacement) { - assert.isTrue(subscribed, "Registry changes must be subscribed before lookup."); - current = pendingReplacement; - pendingReplacement = undefined; - yield* PubSub.publish(changes, undefined); - } - return instance; - }), - }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({}), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}), - ), - ), - ); - return { - service, - replace: Effect.fn(function* (replacement: ProviderInstance | undefined) { - current = replacement; - yield* PubSub.publish(changes, undefined); - }), - }; - }, -); - -const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* ( - stream: Stream.Stream, -) { - const states = yield* Queue.unbounded(); - const fiber = yield* stream.pipe( - Stream.runForEach((state) => Queue.offer(states, state)), - Effect.forkScoped, - ); - return { states, fiber }; -}); - -describe("ProviderAuthService", () => { - it.effect( - "stops sessions sharing credentials and invalidates their processes before logout", - () => - Effect.gen(function* () { - const { service, actions, sessions } = yield* makeHarness({ - sharedCredentials: true, - ...sessionThreads([ - makeSession("target"), - makeSession("shared", "ready", otherInstanceId), - makeSession("unrelated", "ready", unsupportedInstanceId), - ]), - }); - yield* service.logout({ instanceId }); - assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("unrelated")]); - assert.isBelow(actions.indexOf("stop:shared"), actions.indexOf("invalidate-shared")); - assert.isBelow(actions.indexOf("invalidate-shared"), actions.indexOf("native-logout")); - }), - ); - it.effect("rejects overlapping changes to a shared sign-in", () => - Effect.gen(function* () { - const { service, actions } = yield* makeHarness({ - sharedCredentials: true, - sharedBusy: true, - }); - for (const task of [service.start({ instanceId }, owner), service.logout({ instanceId })]) { - const error = yield* task.pipe(Effect.flip); - assert.include(error.detail, "shared sign-in"); - } - assert.deepStrictEqual(actions, []); - }), - ); - it.effect("routes typed interactions to the flow owner and rejects unsupported controllers", () => - Effect.gen(function* () { - const { service, actions } = yield* makeHarness({ responds: true }); - yield* service.start({ instanceId }, owner); - const request = { - instanceId, - flowId, - interactionId: "consent", - response: { type: "browser" as const, action: "accept" as const }, - }; - const rejected = yield* service.respond(request, otherOwner).pipe(Effect.flip); - assert.strictEqual(rejected.operation, "respond"); - yield* service.respond(request, owner); - assert.strictEqual(actions.at(-1), "respond:browser"); - const unsupported = yield* makeHarness(); - const error = yield* unsupported.service.respond(request, owner).pipe(Effect.flip); - assert.include(error.detail, "does not accept"); - }), - ); - it.effect("stops routed sessions before sign-in, including for a disabled instance", () => - Effect.gen(function* () { - const { service, actions, released } = yield* makeHarness({ - enabled: false, - threads: [makeThread("active")], - sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]), - }); - const state = yield* service.start({ instanceId }, owner); - - assert.strictEqual(state.instanceId, instanceId); - assert.strictEqual(state.phase, "waiting"); - assert.deepStrictEqual(released, ["active-session"]); - assert.deepStrictEqual(actions, [ - "close-gate", - "list-threads", - "sessions:active", - "stop:active-session", - "start-sign-in", - ]); - }), - ); - - it.effect("keeps sign-in state private and accepts the owner's redirect URL", () => - Effect.gen(function* () { - const { service } = yield* makeHarness(); - const waiting = yield* service.start({ instanceId }, owner); - const ownerStates = yield* service - .subscribe({ instanceId }, owner) - .pipe(Stream.take(1), Stream.runCollect); - const otherStates = yield* service - .subscribe({ instanceId }, otherOwner) - .pipe(Stream.take(1), Stream.runCollect); - - assert.deepStrictEqual(ownerStates, [waiting]); - assert.strictEqual(otherStates[0]?.authorizationUrl, null); - assert.strictEqual(otherStates[0]?.flowId, null); - - const otherError = yield* Effect.flip( - service.complete({ instanceId, flowId, callbackUrl }, otherOwner), - ); - assert.strictEqual(otherError.operation, "complete"); - const complete = yield* service.complete({ instanceId, flowId, callbackUrl }, owner); - assert.strictEqual(complete.phase, "succeeded"); - assert.strictEqual(complete.authorizationUrl, null); - }), - ); - - it.effect("lets only the flow owner cancel sign-in", () => - Effect.gen(function* () { - const { service } = yield* makeHarness(); - yield* service.start({ instanceId }, owner); - const error = yield* Effect.flip(service.cancel({ instanceId, flowId }, otherOwner)); - assert.strictEqual(error.operation, "cancel"); - - const cancelled = yield* service.cancel({ instanceId, flowId }, owner); - assert.strictEqual(cancelled.phase, "cancelled"); - assert.strictEqual(cancelled.authorizationUrl, null); - }), - ); - - it.effect.each([ - { change: "enable", initialEnabled: false, closeBeforeReplacement: true }, - { change: "config", initialEnabled: true, closeBeforeReplacement: false }, - ])( - "keeps private auth subscriptions current after an instance $change change", - ({ initialEnabled, closeBeforeReplacement }) => - Effect.gen(function* () { - const first = yield* makeStreamingController(owner); - const replacement = yield* makeStreamingController(otherOwner); - yield* SubscriptionRef.set(first.state, waitingAuthState); - const { service, replace } = yield* makeSubscriptionHarness( - makeInstance({ instanceId, enabled: initialEnabled, auth: first.auth }), - ); - const firstClient = yield* observeAuth(service.subscribe({ instanceId }, owner)); - const secondClient = yield* observeAuth(service.subscribe({ instanceId }, otherOwner)); - assert.deepStrictEqual(yield* Queue.take(firstClient.states), waitingAuthState); - assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState); - - if (closeBeforeReplacement) { - yield* first.close; - const closed = yield* Queue.takeN(first.closedSubscriptions, 2); - assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner])); - } - yield* replace(makeInstance({ instanceId, enabled: true, auth: replacement.auth })); - assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState); - assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState); - if (!closeBeforeReplacement) { - const closed = yield* Queue.takeN(first.closedSubscriptions, 2); - assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner])); - } - - const staleState: ProviderAuthState = { ...idleAuthState, phase: "cancelled" }; - yield* SubscriptionRef.set(first.state, staleState); - const newWaiting: ProviderAuthState = { - ...waitingAuthState, - flowId: "replacement-flow", - authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=replacement-state", - }; - yield* SubscriptionRef.set(replacement.state, newWaiting); - assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState); - assert.deepStrictEqual(yield* Queue.take(secondClient.states), newWaiting); - }), - ); - - it.effect("does not miss a replacement during the first instance lookup", () => - Effect.gen(function* () { - const first = yield* makeStreamingController(owner); - const replacement = yield* makeStreamingController(owner); - yield* SubscriptionRef.set(replacement.state, waitingAuthState); - const { service } = yield* makeSubscriptionHarness( - makeInstance({ instanceId, enabled: false, auth: first.auth }), - makeInstance({ instanceId, enabled: true, auth: replacement.auth }), - ); - - const states = yield* service.subscribe({ instanceId }, owner).pipe( - Stream.filter((state) => state.phase === "waiting"), - Stream.take(1), - Stream.runCollect, - ); - - assert.deepStrictEqual(states, [waitingAuthState]); - }), - ); - - it.effect("ends the subscription with a setup error when the instance is removed", () => - Effect.gen(function* () { - const controller = yield* makeStreamingController(owner); - const { service, replace } = yield* makeSubscriptionHarness( - makeInstance({ instanceId, enabled: true, auth: controller.auth }), - ); - const { states, fiber } = yield* observeAuth(service.subscribe({ instanceId }, owner)); - assert.deepStrictEqual(yield* Queue.take(states), idleAuthState); - - yield* replace(undefined); - const error = yield* Effect.flip(Fiber.join(fiber)); - - assert.instanceOf(error, ProviderSetupError); - assert.strictEqual(error.instanceId, instanceId); - assert.strictEqual(error.operation, "subscribe"); - assert.include(error.detail, "no longer available"); - assert.strictEqual(yield* Queue.take(controller.closedSubscriptions), owner); - }), - ); - - it.effect.each([ - { id: ProviderInstanceId.make("missing"), detail: "no longer available" }, - { id: unsupportedInstanceId, detail: "does not support sign-in" }, - ])("rejects setup for unavailable or unsupported instance $id", ({ id, detail }) => - Effect.gen(function* () { - const { service, actions } = yield* makeHarness(); - const operations: Effect.Effect< - ProviderAuthState | ReadonlyArray, - ProviderSetupError - >[] = [ - service.start({ instanceId: id }, owner), - service.complete({ instanceId: id, flowId, callbackUrl }, owner), - service.cancel({ instanceId: id, flowId }, owner), - service.logout({ instanceId: id }), - Stream.runCollect(service.subscribe({ instanceId: id }, owner)), - ]; - for (const operation of operations) { - const error = yield* Effect.flip(operation); - assert.instanceOf(error, ProviderSetupError); - assert.strictEqual(error.instanceId, id); - assert.include(error.detail, detail); - } - assert.deepStrictEqual(actions, []); - }), - ); - - it.effect("stops every live session on the instance before native logout", () => - Effect.gen(function* () { - const { service, actions, released } = yield* makeHarness({ - threads: [ - makeThread("running"), - makeThread("starting"), - makeThread("idle", { active: false }), - makeThread("other-instance", { providerInstanceId: otherInstanceId }), - ], - sessions: new Map([ - [ - ThreadId.make("running"), - [ - makeSession("running-session", "running"), - makeSession("stopped-session", "stopped"), - makeSession("errored-session", "error"), - makeSession("foreign-session", "ready", otherInstanceId), - ], - ], - [ThreadId.make("starting"), [makeSession("starting-session", "starting")]], - [ThreadId.make("idle"), [makeSession("idle-session")]], - [ - ThreadId.make("other-instance"), - [makeSession("other-session", "ready", otherInstanceId)], - ], - ]), - }); - const state = yield* service.logout({ instanceId }); - - assert.strictEqual(state.phase, "idle"); - assert.deepStrictEqual(actions, [ - "close-gate", - "list-threads", - "sessions:running", - "stop:running-session", - "sessions:starting", - "stop:starting-session", - "sessions:idle", - "stop:idle-session", - "sessions:other-instance", - "native-logout", - ]); - assert.deepStrictEqual(released, ["running-session", "starting-session", "idle-session"]); - }), - ); - - it.effect( - "releases a shared native session once after a thread's selected provider changes", - () => - Effect.gen(function* () { - const shared = makeSession("shared-native-session"); - const { service, released } = yield* makeHarness({ - threads: [ - makeThread("changed-selection", { providerInstanceId: otherInstanceId }), - makeThread("same-session"), - ], - sessions: new Map([ - [ThreadId.make("changed-selection"), [shared]], - [ThreadId.make("same-session"), [shared]], - ]), - }); - - yield* service.logout({ instanceId }); - - assert.deepStrictEqual(released, [shared.id]); - }), - ); - - it.effect.each([ - { text: "/logout", hasAttachments: false, handled: true }, - { text: " \n/logout\t", hasAttachments: false, handled: true }, - { text: "/logout", hasAttachments: true, handled: false }, - { text: "/logout please", hasAttachments: false, handled: false }, - { text: "Explain /logout", hasAttachments: false, handled: false }, - { text: "/Logout", hasAttachments: false, handled: false }, - ])("handles only a standalone logout command %#", ({ text, hasAttachments, handled }) => - Effect.gen(function* () { - const { service, actions } = yield* makeHarness(); - const result = yield* service.tryHandlePromptCommand({ instanceId, text, hasAttachments }); - - assert.strictEqual(result, handled); - assert.deepStrictEqual( - actions, - handled ? ["close-gate", "list-threads", "native-logout"] : [], - ); - }), - ); - - it.effect("does not intercept commands for providers without an auth controller", () => - Effect.gen(function* () { - const { service, actions } = yield* makeHarness(); - for (const id of [unsupportedInstanceId, ProviderInstanceId.make("missing")]) { - assert.isFalse( - yield* service.tryHandlePromptCommand({ - instanceId: id, - text: "/logout", - hasAttachments: false, - }), - ); - } - assert.deepStrictEqual(actions, []); - }), - ); - - it.effect("does not log out when the thread shell cannot be read", () => - Effect.gen(function* () { - const { service, actions } = yield* makeHarness({ shellError: true }); - const error = yield* Effect.flip(service.logout({ instanceId })); - - assert.instanceOf(error, ProviderSetupError); - assert.strictEqual(error.instanceId, instanceId); - assert.strictEqual(error.operation, "stopSessions"); - assert.notInclude(error.detail, "private database diagnostics"); - assert.deepStrictEqual(actions, ["close-gate", "list-threads"]); - }), - ); - - it.effect("does not log out or consume the command when stopping a session fails", () => - Effect.gen(function* () { - const { service, actions, released } = yield* makeHarness({ - threads: [makeThread("active")], - sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]), - stopError: true, - }); - const error = yield* Effect.flip( - service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), - ); - - assert.instanceOf(error, ProviderSetupError); - assert.strictEqual(error.operation, "stopSessions"); - assert.notInclude(error.detail, "private process diagnostics"); - assert.deepStrictEqual(released, []); - assert.deepStrictEqual(actions, [ - "close-gate", - "list-threads", - "sessions:active", - "stop:active-session", - ]); - }), - ); - - it.effect("returns native logout failures to the command caller", () => - Effect.gen(function* () { - const logoutError = new ProviderSetupError({ - instanceId, - operation: "logout", - detail: "Native sign-out failed. Try again.", - }); - const { service } = yield* makeHarness({ logoutError }); - const error = yield* Effect.flip( - service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), - ); - - assert.strictEqual(error, logoutError); - }), - ); -}); - -it.effect("queued logout prompts resolve the current controller after provider replacement", () => - Effect.gen(function* () { - const entered = yield* Deferred.make(); - const release = yield* Deferred.make(); - const lookup = yield* Deferred.make(); - let observeLookup = false; - const harness = yield* makeHarness({ - beforeLogout: Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Deferred.await(release)), - ), - onLookup: Effect.suspend(() => - observeLookup ? Deferred.succeed(lookup, undefined).pipe(Effect.asVoid) : Effect.void, - ), - }); - const first = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); - yield* Deferred.await(entered); - observeLookup = true; - const queued = yield* harness.service - .tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }) - .pipe(Effect.forkChild); - yield* Deferred.await(lookup); - let replacementLoggedOut = false; - harness.replaceAuth({ - ...harness.auth, - logout: (stopSessions) => - stopSessions.pipe( - Effect.andThen( - Effect.sync(() => { - replacementLoggedOut = true; - return idleAuthState; - }), - ), - ), - }); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(first); - assert.isTrue(yield* Fiber.join(queued)); - assert.isTrue(replacementLoggedOut); - assert.strictEqual(harness.actions.filter((action) => action === "native-logout").length, 1); - }).pipe(Effect.scoped), -); - -it.effect.each(["start", "logout", "prompt"] as const)( - "%s uses the same credential controller for shared exclusion and mutation during replacement", - (action) => - Effect.gen(function* () { - const checked = yield* Deferred.make(); - const continueCheck = yield* Deferred.make(); - const replacementPeerId = ProviderInstanceId.make("replacement-shared-peer"); - const harness = yield* makeHarness({ - sharedCredentials: true, - sharedBusyEffect: Deferred.succeed(checked, undefined).pipe( - Effect.andThen(Deferred.await(continueCheck)), - Effect.as(false), - ), - ...sessionThreads([ - makeSession("old-shared", "ready", otherInstanceId), - makeSession("replacement-shared", "ready", replacementPeerId), - ]), - }); - harness.addInstance( - makeInstance({ - instanceId: replacementPeerId, - enabled: true, - auth: { - ...harness.auth, - credentialBinding: { owner: "provider", key: "replacement-binding" }, - isChangingCredentials: Effect.succeed(true), - invalidate: Effect.die( - "The unrelated replacement credential binding must stay intact.", - ), - }, - }), - ); - const operation = - action === "start" - ? harness.service.start({ instanceId }, owner) - : action === "logout" - ? harness.service.logout({ instanceId }) - : harness.service.tryHandlePromptCommand({ - instanceId, - text: "/logout", - hasAttachments: false, - }); - const running = yield* operation.pipe(Effect.forkChild); - yield* Deferred.await(checked); - let replacementMutations = 0; - harness.replaceAuth({ - ...harness.auth, - credentialBinding: { owner: "provider", key: "replacement-binding" }, - start: () => - Effect.sync(() => { - replacementMutations++; - return waitingAuthState; - }), - logout: () => - Effect.sync(() => { - replacementMutations++; - return idleAuthState; - }), - }); - yield* Deferred.succeed(continueCheck, undefined); - yield* Fiber.join(running); - assert.equal(replacementMutations, 0); - assert.include(harness.actions, action === "start" ? "start-sign-in" : "native-logout"); - assert.isFalse(harness.sessions.has(ThreadId.make("old-shared"))); - assert.isTrue(harness.sessions.has(ThreadId.make("replacement-shared"))); - const blocked = yield* Effect.flip(harness.service.logout({ instanceId })); - assert.include(blocked.detail, "shared sign-in"); - assert.equal(replacementMutations, 0); - }).pipe(Effect.scoped), -); - -it.effect.each([ - { owner: "provider" as const, key: "different-binding" }, - { owner: "t3" as const, key: "shared" }, -])( - "does not invalidate a peer that switches credential binding during session draining %#", - (binding) => - Effect.gen(function* () { - const draining = yield* Deferred.make(); - const continueDrain = yield* Deferred.make(); - const harness = yield* makeHarness({ - sharedCredentials: true, - ...sessionThreads([makeSession("shared-draining", "ready", otherInstanceId)]), - beforeStop: Deferred.succeed(draining, undefined).pipe( - Effect.andThen(Deferred.await(continueDrain)), - ), - }); - const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); - yield* Deferred.await(draining); - let replacementInvalidated = false; - harness.replaceInstance( - makeInstance({ - instanceId: otherInstanceId, - enabled: true, - auth: { - ...harness.auth, - credentialBinding: binding, - invalidate: Effect.sync(() => { - replacementInvalidated = true; - }), - }, - }), - ); - yield* Deferred.succeed(continueDrain, undefined); - yield* Fiber.join(logout); - assert.isFalse(replacementInvalidated); - assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining"))); - assert.include(harness.actions, "native-logout"); - }).pipe(Effect.scoped), -); - -it.effect.each(["selection", "drain"] as const)( - "preserves replacement peer sessions when credentials change during session %s", - (phase) => - Effect.gen(function* () { - const entered = yield* Deferred.make(); - const proceed = yield* Deferred.make(); - const block = Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Deferred.await(proceed)), - ); - const harness = yield* makeHarness({ - sharedCredentials: true, - ...sessionThreads([ - makeSession("target-draining"), - makeSession("peer-replacement", "ready", otherInstanceId), - makeSession("peer-persisted", "running", otherInstanceId), - ]), - ...(phase === "selection" ? { beforeListSessions: block } : { beforeStop: block }), - }); - const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); - yield* Deferred.await(entered); - harness.replaceInstance( - makeInstance({ - instanceId: otherInstanceId, - enabled: true, - auth: { - ...harness.auth, - credentialBinding: { owner: "provider", key: "replacement-credentials" }, - invalidate: Effect.die("The replacement peer's credentials must not be invalidated."), - }, - }), - ); - yield* Deferred.succeed(proceed, undefined); - yield* Fiber.join(logout); - assert.isTrue(harness.sessions.has(ThreadId.make("peer-replacement"))); - assert.notInclude(harness.actions, "stop:peer-replacement"); - assert.notInclude(harness.actions, "stop:peer-persisted"); - assert.isFalse(harness.sessions.has(ThreadId.make("target-draining"))); - assert.include(harness.actions, "native-logout"); - }).pipe(Effect.scoped), -); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.ts b/apps/server/src/provider/Layers/ProviderAuthService.ts deleted file mode 100644 index 925607dd334e35d9972fdccc82bbf3ccaea68ee2..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ProviderAuthService.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { - ProviderSetupError, - type ProviderInstanceId, - type ProviderSessionId, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; -import * as Semaphore from "effect/Semaphore"; - -import * as ProviderSessionManager from "../../orchestration-v2/ProviderSessionManager.ts"; -import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; -import * as ProviderAuthService from "../Services/ProviderAuthService.ts"; -import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; - -export const makeProviderAuthService = Effect.gen(function* () { - const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; - const projections = yield* ProjectionStore.ProjectionStoreV2; - const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; - const credentialChanges = yield* Semaphore.make(1); - - const getController = Effect.fn("ProviderAuthService.getController")(function* ( - instanceId: ProviderInstanceId, - operation: string, - ) { - const instance = yield* registry.getInstance(instanceId); - if (!instance?.auth) { - return yield* new ProviderSetupError({ - instanceId, - operation, - detail: instance - ? "This provider does not support sign-in in T3 Code." - : "This provider instance is no longer available.", - }); - } - return instance.auth; - }); - - // Native sessions may still belong to the previous provider after the - // selected model changes. Read session bindings, not the selected model, - // when invalidating credentials for sign-in or sign-out. - const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( - instanceId: ProviderInstanceId, - binding: ProviderAuthService.ProviderAuthController["credentialBinding"], - ) { - const failure = (detail: string) => - new ProviderSetupError({ instanceId, operation: "stopSessions", detail }); - const affectedIds = new Set([ - instanceId, - ...(binding === undefined - ? [] - : (yield* registry.listInstances) - .filter( - (instance) => - instance.auth?.credentialBinding?.key === binding.key && - instance.auth.credentialBinding.owner === binding.owner, - ) - .map((instance) => instance.instanceId)), - ]); - const threadIds = yield* projections - .getRecoveryThreadIds("runtime") - .pipe( - Effect.mapError(() => failure("Could not read the provider's active sessions. Try again.")), - ); - const released = new Set(); - yield* Effect.forEach( - threadIds, - (threadId) => - projections.getThreadRecords(threadId, ["providerSessions"]).pipe( - Effect.flatMap((projection) => - Effect.forEach( - projection.providerSessions.filter( - (session) => - affectedIds.has(session.providerInstanceId) && - session.status !== "stopped" && - session.status !== "error" && - !released.has(session.id), - ), - (session) => - Effect.gen(function* () { - if (session.providerInstanceId !== instanceId) { - const current = yield* registry.getInstance(session.providerInstanceId); - if ( - !binding || - current?.auth?.credentialBinding?.key !== binding.key || - current.auth.credentialBinding.owner !== binding.owner - ) - return; - } - yield* providerSessions - .release({ - providerSessionId: session.id, - reason: "manual_shutdown", - detail: "Provider sign-in changed.", - }) - .pipe(Effect.tap(() => Effect.sync(() => released.add(session.id)))); - }), - { discard: true }, - ), - ), - Effect.mapError(() => - failure("Could not stop all sessions for this provider. Try again."), - ), - ), - { discard: true }, - ); - if (binding) { - yield* Effect.forEach( - (yield* registry.listInstances).filter( - (instance) => - instance.instanceId !== instanceId && - affectedIds.has(instance.instanceId) && - instance.auth?.credentialBinding?.key === binding.key && - instance.auth.credentialBinding.owner === binding.owner, - ), - (instance) => instance.auth?.invalidate ?? Effect.void, - { discard: true }, - ); - } - }); - - const checkSharedBinding = Effect.fnUntraced(function* ( - instanceId: ProviderInstanceId, - operation: "start" | "logout", - auth: ProviderAuthService.ProviderAuthController, - ) { - const binding = auth.credentialBinding; - if (!binding) return; - const instances = yield* registry.listInstances; - for (const instance of instances) { - if ( - instance.instanceId !== instanceId && - instance.auth?.credentialBinding?.key === binding.key && - instance.auth.credentialBinding.owner === binding.owner && - instance.auth.isChangingCredentials && - (yield* instance.auth.isChangingCredentials) - ) { - return yield* new ProviderSetupError({ - instanceId, - operation, - detail: - "Another provider instance is changing this shared sign-in. Finish or cancel it first.", - }); - } - } - }); - - return ProviderAuthService.ProviderAuthService.of({ - reconnectProfile: Effect.fnUntraced(function* (input) { - const auth = yield* getController(input.instanceId, "export"); - if (!auth.reconnectProfile) - return yield* new ProviderSetupError({ - instanceId: input.instanceId, - operation: "export", - detail: "This provider does not support ChatGPT profile transfer.", - }); - return yield* auth.reconnectProfile(input.methodId); - }), - importProfile: (input) => - credentialChanges.withPermit( - Effect.gen(function* () { - const auth = yield* getController(input.instanceId, "import"); - yield* checkSharedBinding(input.instanceId, "start", auth); - if (!auth.importProfile) - return yield* new ProviderSetupError({ - instanceId: input.instanceId, - operation: "import", - detail: "This provider does not support ChatGPT profile transfer.", - }); - return yield* auth.importProfile( - input.profile, - stopSessions(input.instanceId, auth.credentialBinding), - ); - }), - ), - start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { - return yield* credentialChanges.withPermit( - Effect.gen(function* () { - const auth = yield* getController(input.instanceId, "start"); - yield* checkSharedBinding(input.instanceId, "start", auth); - return yield* auth.start( - ownerSessionId, - stopSessions(input.instanceId, auth.credentialBinding), - input.methodId, - input.returnUrl, - input.callbackMode, - ); - }), - ); - }), - respond: Effect.fn("ProviderAuthService.respond")(function* (input, ownerSessionId) { - const auth = yield* getController(input.instanceId, "respond"); - if (!auth.respond) { - return yield* new ProviderSetupError({ - instanceId: input.instanceId, - operation: "respond", - detail: "This provider does not accept this sign-in interaction.", - }); - } - return yield* auth.respond(ownerSessionId, input); - }), - complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { - const auth = yield* getController(input.instanceId, "complete"); - return yield* auth.complete(ownerSessionId, input); - }), - cancel: Effect.fn("ProviderAuthService.cancel")(function* (input, ownerSessionId) { - const auth = yield* getController(input.instanceId, "cancel"); - return yield* auth.cancel(ownerSessionId, input.flowId); - }), - logout: Effect.fn("ProviderAuthService.logout")(function* (input) { - return yield* credentialChanges.withPermit( - Effect.gen(function* () { - const auth = yield* getController(input.instanceId, "logout"); - yield* checkSharedBinding(input.instanceId, "logout", auth); - return yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); - }), - ); - }), - subscribe: (input, ownerSessionId) => - Effect.gen(function* () { - const changes = yield* registry.subscribeChanges; - const initial = yield* getController(input.instanceId, "subscribe"); - return Stream.concat( - Stream.succeed(initial), - Stream.fromSubscription(changes).pipe( - Stream.mapEffect(() => getController(input.instanceId, "subscribe")), - ), - ).pipe( - Stream.changesWith((previous, next) => previous === next), - Stream.switchMap((auth) => auth.subscribe(ownerSessionId)), - ); - }).pipe(Stream.unwrap), - tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")( - function* (input) { - const instance = yield* registry.getInstance(input.instanceId); - if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { - return false; - } - return yield* credentialChanges.withPermit( - Effect.gen(function* () { - const auth = yield* getController(input.instanceId, "logout"); - if (!auth.isLogoutPrompt?.(input.text, input.hasAttachments)) return false; - yield* checkSharedBinding(input.instanceId, "logout", auth); - yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); - return true; - }), - ); - }, - ), - }); -}); - -export const layer = Layer.effect(ProviderAuthService.ProviderAuthService, makeProviderAuthService); diff --git a/apps/server/src/provider/Layers/ProviderEventLoggers.ts b/apps/server/src/provider/Layers/ProviderEventLoggers.ts deleted file mode 100644 index 654927440c56a35443784eca5bfd2d5d99ac3d74..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ProviderEventLoggers.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * ProviderEventLoggers — single observability service that owns the shared - * provider event log store and exposes its two runtime views: - * - * - `native` — provider-protocol events as the SDK emits them, written - * from inside each `Adapter` factory. - * - `canonical` — runtime events after `ProviderService` has normalized - * them onto `ProviderRuntimeEvent`. - * - * Why a service tag and not constructor options? - * - * - Adapters are now constructed *inside* drivers (`Driver.create()`), - * not at the boot Layer. There is no longer a single `makeAdapterLive(options)` - * call site where we can hand an `EventNdjsonLogger` in by hand. - * - Multiple driver instances per kind (`codex_personal`, `codex_work`) - * must share one underlying log store — opening N writers against the - * same rotating file would race the rotation logic. Owning the loggers on - * a single tag keeps that invariant intact. - * - Tests can swap one (or both) loggers with in-memory recorders by - * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of - * juggling per-Layer option threading. - * - * Both fields are optional because observability must not prevent startup. - * - * @module provider/Layers/ProviderEventLoggers - */ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import * as ServerConfig from "../../config.ts"; -import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; -import * as EventNdjsonLogger from "./EventNdjsonLogger.ts"; - -/** - * Shared logger pair for native + canonical provider event streams. - * - * Service value is intentionally a struct of two optional loggers rather - * than two parallel tags. Construction site is one place - * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the - * field they need. - */ -export class ProviderEventLoggers extends Context.Service< - ProviderEventLoggers, - { - readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined; - readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined; - } ->()("t3/provider/Layers/ProviderEventLoggers") {} - -/** - * Constant value used by tests / boot layers that want to opt out of native - * + canonical logging entirely. Keeps the tag non-optional in the type - * system while letting the runtime treat absence as a no-op. - */ -export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { - native: undefined, - canonical: undefined, -}; - -/** - * Builds both stream views over one shared store. Setup failures are logged - * and downgraded to the no-op service so diagnostics never block startup. - * - * @public Service construction is part of the canonical Effect module API. - */ -export const make = Effect.gen(function* () { - const { providerEventLogPath } = yield* ServerConfig.ServerConfig; - const attribution = yield* ResourceAttribution.ResourceAttribution; - const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, { - attribution, - }).pipe( - Effect.catch((error) => - Effect.logWarning(error.message, { error }).pipe( - Effect.annotateLogs({ scope: "provider-observability" }), - Effect.as(undefined), - ), - ), - ); - - if (!store) { - return ProviderEventLoggers.of(NoOpProviderEventLoggers); - } - - yield* Effect.addFinalizer(() => store.close()); - return ProviderEventLoggers.of({ - native: store.logger("native"), - canonical: store.logger("canonical"), - }); -}); - -export const layer = Layer.effect(ProviderEventLoggers, make); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts deleted file mode 100644 index 33d728fc4d4aea212e9051205e9ffea535a5e357..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` - * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. - * - * The server still reads two shapes: - * - * 1. `settings.providerInstances` — the new driver-agnostic map the - * registry expects. Keyed by `ProviderInstanceId`, values are - * `ProviderInstanceConfig` envelopes. - * 2. `settings.providers.` — the legacy single-instance-per-driver - * fields (`providers.codex`, `providers.claudeAgent`, …). These are - * the source of truth for every deployment that hasn't been migrated - * yet to an explicit `providerInstances` entry. - * - * This module bridges (2) into (1) and wires the resulting map into a - * mutable registry. For every built-in driver whose id is not already - * present in `providerInstances` (keyed on - * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a - * routing slug), we synthesize an envelope from the legacy field. The - * registry decodes both flavours through the same `configSchema` and ends - * up with one uniform `ProviderInstance` per entry. - * - * Explicit `providerInstances` entries always win — users can already - * override the legacy `providers.` blob by authoring a - * `providerInstances.codex` entry with a matching driver, and we don't - * want the synthesized envelope to silently stomp their config. - * - * Hot-reload - * ---------- - * On layer build we: - * 1. Read the current `ServerSettings` once and use it to seed the - * registry's initial state via `ProviderInstanceRegistryLive.layer`. - * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that - * acquires `ServerSettingsService.subscribeChanges` and calls - * `ProviderInstanceRegistryMutator.reconcile` on every emission. - * - * Failures inside the watcher are logged and swallowed so a single bad - * settings emission cannot kill the registry. Unknown drivers and invalid - * configs already round-trip through the registry's own "unavailable" - * shadow bucket. - * - * @module provider/Layers/ProviderInstanceRegistryHydration - */ -import { - defaultInstanceIdForDriver, - type ProviderInstanceConfig, - type ProviderInstanceConfigMap, - ServerSettings, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; - -import * as Settings from "../../serverSettings.ts"; -import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts"; -import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; -import * as ProviderInstanceRegistryMutator from "../Services/ProviderInstanceRegistryMutator.ts"; -import * as ProviderInstanceRegistryLive from "./ProviderInstanceRegistryLive.ts"; -import { type ProviderOrchestrationAdapterInfrastructure } from "./ProviderOrchestrationAdapterInfrastructure.ts"; -import * as ProviderOrchestrationAdapterInfrastructureLayer from "./ProviderOrchestrationAdapterInfrastructure.ts"; -import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts"; -import * as AcpRegistryCatalogLayer from "./AcpRegistryCatalog.ts"; - -type ProviderInstanceRegistryHydrationEnv = - | Exclude< - BuiltInDriversEnv, - ProviderOrchestrationAdapterInfrastructure | AcpRegistrySupport.AcpRegistryCatalog - > - | Settings.ServerSettingsService; - -/** - * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. - * - * Strategy: - * 1. Copy all explicit `settings.providerInstances` entries verbatim. - * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key - * is *not* already in the explicit map, synthesize an entry from the - * matching legacy `settings.providers.` blob. - * - * The returned map is the input the registry consumes; pure & exported - * separately so the hydration logic can be exercised by unit tests - * without layering. - */ -export const deriveProviderInstanceConfigMap = ( - settings: ServerSettings, -): ProviderInstanceConfigMap => { - const merged: Record = { ...settings.providerInstances }; - - for (const driver of BUILT_IN_DRIVERS) { - const instanceId = defaultInstanceIdForDriver(driver.driverKind); - if (instanceId in merged) { - // Explicit `providerInstances` entry for this slot — user-authored - // config always wins over the legacy mirror. - continue; - } - - // Only built-in drivers have a legacy mirror; the registry's - // `providers` struct is keyed on the same literal slug as - // `driverKind`. Access is dynamic (the driver kind is a branded string), - // but it's constrained to `keyof settings.providers` by the union of - // built-in driver kinds. - const legacyKey = driver.driverKind as keyof ServerSettings["providers"]; - const legacyConfig = settings.providers[legacyKey]; - if (legacyConfig === undefined) { - continue; - } - - merged[instanceId] = { - driver: driver.driverKind, - config: legacyConfig, - }; - } - - return merged as ProviderInstanceConfigMap; -}; - -/** - * Layer that consumes `ProviderInstanceRegistryMutator` and forks a - * settings-watcher fiber. The fiber's lifetime is tied to the enclosing - * layer scope (process lifetime in production), so it is interrupted on - * shutdown without leaking. - * - * Errors inside the watcher are logged and swallowed — the registry's own - * "unavailable" bucket already absorbs unknown drivers and invalid - * configs, so the only way the watcher could fail is a settings stream - * tear-down, which logs and exits cleanly. - */ -const layerSettingsWatcher = Layer.effectDiscard( - Effect.gen(function* () { - const mutator = yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator; - const serverSettings = yield* Settings.ServerSettingsService; - const settingsChanges = yield* serverSettings.subscribeChanges; - yield* settingsChanges.pipe( - Stream.runForEach((next) => - mutator - .reconcile(deriveProviderInstanceConfigMap(next)) - .pipe( - Effect.catchCause((cause) => - Effect.logError("ProviderInstanceRegistry reconcile failed", cause), - ), - ), - ), - Effect.forkScoped, - ); - }), -); - -/** - * Hydrate `ProviderInstanceRegistry` from `ServerSettings` and keep it in - * sync with subsequent `streamChanges` emissions. - * - * The Layer's two halves: - * - `ProviderInstanceRegistryLive.layer` produces the registry + - * mutator from the initial config map. Its scope owns every - * per-instance child scope created during reconcile. - * - `SettingsWatcherLive` consumes the mutator, acquires its settings - * subscription before forking, and runs a daemon fiber in the same scope. - * - * Composing via `Layer.provideMerge` makes the watcher's deps available - * from the mutable layer while still surfacing the registry as an output. - * The mutator tag is technically also exposed; only this module imports - * it, so the visibility leak is harmless in practice. - */ -export const layer: Layer.Layer< - ProviderInstanceRegistry.ProviderInstanceRegistry, - never, - ProviderInstanceRegistryHydrationEnv -> = Layer.unwrap( - Effect.gen(function* () { - const serverSettings = yield* Settings.ServerSettingsService; - const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( - Effect.orElseSucceed(() => undefined), - ); - const initialConfigMap = - initialSettings === undefined - ? ({} as ProviderInstanceConfigMap) - : deriveProviderInstanceConfigMap(initialSettings); - - const layerMutable = ProviderInstanceRegistryLive.layer({ - drivers: BUILT_IN_DRIVERS, - configMap: initialConfigMap, - }).pipe( - Layer.provide(ProviderOrchestrationAdapterInfrastructureLayer.layer), - Layer.provide(AcpRegistryCatalogLayer.layer), - ); - - return layerSettingsWatcher.pipe(Layer.provideMerge(layerMutable)); - }), -) as Layer.Layer< - ProviderInstanceRegistry.ProviderInstanceRegistry, - never, - ProviderInstanceRegistryHydrationEnv ->; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts deleted file mode 100644 index 428f7444f05a9783ca34f9b747e9458941102ef7..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ /dev/null @@ -1,781 +0,0 @@ -import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; -import * as CodexInstallation from "../CodexInstallation.ts"; -import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; -/** - * Multi-instance validation slices for `ProviderInstanceRegistryLive`. - * - * Two axes of the driver/registry refactor are exercised here: - * - * 1. **Same driver, many instances** — the "multi-instance codex slice" - * describe block below configures two independent `codex` instances and - * asserts each gets its own closures and identity. This is the - * multi-codex capability the refactor exists to unlock. - * - * 2. **Many drivers, one registry** — the "all drivers slice" describe - * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single - * `ProviderInstanceConfigMap` and asserts the registry boots them all - * without cross-contamination. This proves the driver SPI is uniform - * across every provider — any driver plugs into the registry through - * the same `ProviderDriver` value contract. - * - * Every instance in these tests is configured with `enabled: false` so the - * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` - * binaries. That keeps the assertions focused on registry routing - * behaviour rather than the runtime details of each provider. - */ -import { describe, expect, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { - EnvironmentId, - type ClaudeSettings, - type CodexSettings, - type CursorSettings, - type GrokSettings, - type OpenCodeSettings, - ProviderDriverKind, - type ProviderInstanceConfigMap, - ProviderInstanceId, -} from "@t3tools/contracts"; -import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientResponse } from "effect/http"; - -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import * as AntigravityInstallation from "../AntigravityInstallation.ts"; -import * as ServerConfig from "../../config.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; -import * as ServerSettings from "../../serverSettings.ts"; -import { ClaudeDriver, type ClaudeDriverEnv } from "../Drivers/ClaudeDriver.ts"; -import { CodexDriver, type CodexDriverEnv } from "../Drivers/CodexDriver.ts"; -import { CursorDriver, type CursorDriverEnv } from "../Drivers/CursorDriver.ts"; -import { GrokDriver, type GrokDriverEnv } from "../Drivers/GrokDriver.ts"; -import { OpenCodeDriver, type OpenCodeDriverEnv } from "../Drivers/OpenCodeDriver.ts"; -import * as ModelManifest from "../ModelManifest.ts"; -import * as OpenCodeRuntime from "../opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; -import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; -import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; -import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; -import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; - -const layerTestHttpClient = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), - ), -); - -const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); - -const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - reportClientActivity: () => Effect.void, - removeRpcClient: () => Effect.void, - reportHostPowerState: () => Effect.void, - snapshot: Effect.succeed({ - hostPower: { - source: "unknown", - idle: "unknown", - idleSeconds: null, - locked: "unknown", - suspended: false, - onBattery: "unknown", - lowPowerMode: "unknown", - thermalState: "unknown", - stale: true, - updatedAt: TEST_EPOCH, - }, - leases: [], - activeForegroundLeaseCount: 0, - activeScopeKeys: [], - shouldRunOpportunisticWork: true, - updatedAt: TEST_EPOCH, - }), - streamChanges: Stream.empty, - hasDemand: () => Effect.succeed(true), - shouldRunScopeWork: () => Effect.succeed(true), - shouldRunOpportunisticWork: Effect.succeed(true), -}); - -const makeCodexConfig = (overrides: Partial): CodexSettings => ({ - enabled: false, - binaryPath: "codex", - homePath: "", - shadowHomePath: "", - launchArgs: "", - customModels: [], - ...overrides, -}); - -const makeClaudeConfig = (overrides: Partial): ClaudeSettings => ({ - enabled: false, - binaryPath: "claude", - homePath: "", - customModels: [], - launchArgs: "", - autoCompactWindow: "", - ...overrides, -}); - -const makeCursorConfig = (overrides: Partial): CursorSettings => ({ - enabled: false, - customModels: [], - ...overrides, -}); - -const makeGrokConfig = (overrides: Partial): GrokSettings => ({ - enabled: false, - binaryPath: "grok", - customModels: [], - ...overrides, -}); - -const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ - enabled: false, - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", - customModels: [], - ...overrides, -}); - -const makeTildeProviderFixtures = Effect.fn( - "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures", -)(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homePath = expandHomePath("~"); - const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ - directory: homePath, - prefix: ".t3-provider-path-test-", - }); - const codexPath = path.join(fixtureDir, "codex"); - const claudePath = path.join(fixtureDir, "claude"); - const claudeHomePath = path.join(fixtureDir, "claude-home"); - const codexScriptPath = path.join(fixtureDir, "codex-script.json"); - const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures"); - - yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); - yield* fileSystem.copyFile( - path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), - path.join(fixtureDir, "codexCollabMockPeer.mjs"), - ); - yield* fileSystem.copyFile( - path.join(codexFixtureDir, "codexMultiAgentWire.json"), - path.join(fixtureDir, "codexMultiAgentWire.json"), - ); - yield* fileSystem.writeFileString( - codexScriptPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. - JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), - ); - yield* fileSystem.chmod(codexPath, 0o755); - - yield* fileSystem.copyFile( - yield* path.fromFileUrl( - new URL("./testing/ProviderInstanceRegistryLive.fixture.mjs", import.meta.url), - ), - claudePath, - ); - yield* fileSystem.chmod(claudePath, 0o755); - yield* fileSystem.makeDirectory(claudeHomePath); - - const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; - return { - codexBinaryPath: asTildePath(codexPath), - claudeBinaryPath: asTildePath(claudePath), - claudeHomePath, - codexScriptPath, - }; -}); - -describe("ProviderInstanceRegistryLive — 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 layerBase = ServerConfig.layerTest(process.cwd(), { - prefix: "provider-instance-registry-test", - }).pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge( - Layer.mock(CodexInstallation.CodexInstallation)({ - managedDirectory: "unused-managed-installation", - }), - ), - Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), - Layer.provideMerge( - Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { - getEnvironmentId: Effect.succeed( - EnvironmentId.make("00000000-0000-4000-8000-000000000001"), - ), - }), - ), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge(layerTestHttpClient), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - ); - const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( - Layer.provideMerge(layerBase), - ); - - it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => - Effect.gen(function* () { - const personalId = ProviderInstanceId.make("codex_personal"); - const workId = ProviderInstanceId.make("codex_work"); - const codexDriverKind = ProviderDriverKind.make("codex"); - - const configMap: ProviderInstanceConfigMap = { - [personalId]: { - driver: codexDriverKind, - displayName: "Codex (personal)", - enabled: false, - config: makeCodexConfig({ - binaryPath: "/opt/codex-personal/bin/codex", - homePath: "/home/julius/.codex_personal", - customModels: ["personal-preview"], - }), - }, - [workId]: { - driver: codexDriverKind, - displayName: "Codex (work)", - enabled: false, - config: makeCodexConfig({ - binaryPath: "/opt/codex-work/bin/codex", - homePath: "/home/julius/.codex", - customModels: ["work-preview"], - }), - }, - }; - - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap, - }); - - const instances = yield* registry.listInstances; - expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [personalId, workId].toSorted(), - ); - expect(instances.every((instance) => instance.driverKind === codexDriverKind)).toBe(true); - expect(instances.map((instance) => instance.displayName).toSorted()).toEqual( - ["Codex (personal)", "Codex (work)"].toSorted(), - ); - - // Each instance must be retrievable by id and carry its *own* closures. - const personal = yield* registry.getInstance(personalId); - const work = yield* registry.getInstance(workId); - expect(personal).toBeDefined(); - expect(work).toBeDefined(); - expect(personal!.orchestrationAdapter).not.toBe(work!.orchestrationAdapter); - expect(personal!.textGeneration).not.toBe(work!.textGeneration); - expect(personal!.snapshot).not.toBe(work!.snapshot); - - // Snapshots identify themselves by instanceId + driver — this is - // what makes per-instance routing distinguishable downstream. - const personalSnapshot = yield* personal!.snapshot.getSnapshot; - expect(personalSnapshot.instanceId).toBe(personalId); - expect(personalSnapshot.driver).toBe(codexDriverKind); - expect(personalSnapshot.enabled).toBe(false); - // The layout resolves the configured home through the host Path. - const path = yield* Path.Path; - expect(personalSnapshot.continuation?.groupKey).toBe( - `codex:home:${path.resolve("/home/julius/.codex_personal")}`, - ); - - const workSnapshot = yield* work!.snapshot.getSnapshot; - expect(workSnapshot.instanceId).toBe(workId); - expect(workSnapshot.driver).toBe(codexDriverKind); - expect(workSnapshot.enabled).toBe(false); - expect(workSnapshot.continuation?.groupKey).toBe( - `codex:home:${path.resolve("/home/julius/.codex")}`, - ); - - // Nothing goes to the unavailable bucket — both drivers are registered. - const unavailable = yield* registry.listUnavailable; - expect(unavailable).toEqual([]); - }).pipe(Effect.provide(layerTest)), - ); - - it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () => - Effect.gen(function* () { - // Old settings files can carry both flags with conflicting values. - // The explicit false must win so a user's disable is never undone. - const staleId = ProviderInstanceId.make("codex_stale"); - const configMap: ProviderInstanceConfigMap = { - [staleId]: { - driver: ProviderDriverKind.make("codex"), - enabled: true, - config: makeCodexConfig({ enabled: false }), - }, - }; - - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap, - }); - - const instance = yield* registry.getInstance(staleId); - expect(instance).toBeDefined(); - expect(instance!.enabled).toBe(false); - const snapshot = yield* instance!.snapshot.getSnapshot; - expect(snapshot.enabled).toBe(false); - }).pipe(Effect.provide(layerTest)), - ); - - it.live("reports Codex's answer when a redemption changed nothing", () => - Effect.gen(function* () { - if (yield* isHostWindows) return; - const fileSystem = yield* FileSystem.FileSystem; - const fixtures = yield* makeTildeProviderFixtures(); - yield* fileSystem.writeFileString( - fixtures.codexScriptPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. - JSON.stringify({ - rootThreadId: "probe-thread", - notifications: [], - account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, - failRateLimitsRead: true, - resetCreditOutcome: "alreadyRedeemed", - }), - ); - const codexId = ProviderInstanceId.make("codex_reset"); - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap: { - [codexId]: { - driver: ProviderDriverKind.make("codex"), - enabled: true, - environment: [ - { name: "T3_CODEX_COLLAB_SCRIPT", value: fixtures.codexScriptPath, sensitive: false }, - ], - config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), - }, - }, - }); - const codex = yield* registry.getInstance(codexId); - expect(codex).toBeDefined(); - // The usage read fails, so the re-probe cannot confirm new limits. - yield* codex!.snapshot.refresh; - expect(yield* codex!.consumeResetCredit!()).toBe("alreadyRedeemed"); - }).pipe(Effect.provide(layerTest)), - ); - - it.live("runs Codex and Claude readiness probes from configured tilde paths", () => - Effect.gen(function* () { - if (yield* isHostWindows) return; - - const fixtures = yield* makeTildeProviderFixtures(); - - const codexId = ProviderInstanceId.make("codex_tilde"); - const claudeId = ProviderInstanceId.make("claude_tilde"); - const configMap: ProviderInstanceConfigMap = { - [codexId]: { - driver: ProviderDriverKind.make("codex"), - enabled: true, - environment: [ - { - name: "T3_CODEX_COLLAB_SCRIPT", - value: fixtures.codexScriptPath, - sensitive: false, - }, - ], - config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), - }, - [claudeId]: { - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - config: makeClaudeConfig({ - enabled: true, - binaryPath: fixtures.claudeBinaryPath, - homePath: fixtures.claudeHomePath, - }), - }, - }; - - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver], - configMap, - }); - const codex = yield* registry.getInstance(codexId); - const claude = yield* registry.getInstance(claudeId); - expect(codex).toBeDefined(); - expect(claude).toBeDefined(); - - const [codexSnapshot, claudeSnapshot] = yield* Effect.all( - [codex!.snapshot.refresh, claude!.snapshot.refresh], - { concurrency: "unbounded" }, - ); - expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); - expect(claudeSnapshot).toMatchObject({ - status: "ready", - installed: true, - version: "2.1.219", - }); - }).pipe(Effect.provide(layerTest)), - ); - - const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const fixtures = yield* makeTildeProviderFixtures(); - const marker = path.join(fixtures.claudeHomePath, "redeemed"); - yield* fs.writeFileString( - path.join(fixtures.claudeHomePath, ".credentials.json"), - '{"claudeAiOauth":{"accessToken":"fake-token"}}', - ); - yield* fs.writeFileString( - path.join(fixtures.claudeHomePath, ".claude.json"), - '{"oauthAccount":{"organizationUuid":"fake-org"}}', - ); - const client = HttpClient.make((request) => - Effect.gen(function* () { - if (request.url.endsWith("/api/oauth/usage")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ - cedar_ember: { - eligible: true, - next_grant_id: "grant_a", - grants: [{ id: "grant_a", resets_left: 1, usable_now: true }], - }, - }), - ); - } - if (request.url.endsWith("/reset_rate_limits")) { - yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie); - return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result })); - } - return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" })); - }), - ); - const instanceId = ProviderInstanceId.make("claude_reset"); - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [ClaudeDriver], - configMap: { - [instanceId]: { - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - environment: [ - { name: "T3_CLAUDE_RESET_MARKER", value: marker, sensitive: false }, - ...(claim.usageFailsAfterClaim - ? [{ name: "T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM", value: "1", sensitive: false }] - : []), - ], - config: makeClaudeConfig({ - enabled: true, - binaryPath: fixtures.claudeBinaryPath, - homePath: fixtures.claudeHomePath, - }), - }, - }, - }).pipe(Effect.provideService(HttpClient.HttpClient, client)); - const instance = yield* registry.getInstance(instanceId); - expect(instance).toBeDefined(); - const before = yield* instance!.snapshot.refresh; - expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); - expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); - const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); - return { outcome, after: yield* instance!.snapshot.getSnapshot }; - }).pipe( - // macOS logins live in the Keychain, where resets are never read. - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provide(layerTest), - ); - - it.live("refreshes Claude usage after redeeming a reset", () => - Effect.gen(function* () { - const { outcome, after } = yield* redeemClaudeReset({ - result: "reset", - usageFailsAfterClaim: false, - }); - expect(outcome).toMatchObject({ _tag: "Success", success: "reset" }); - expect(after.usageLimits?.windows[0]?.usedPercent).toBe(0); - }), - ); - - it.live("reports Claude's answer when a claim changed nothing and the re-probe fails", () => - Effect.gen(function* () { - const { outcome } = yield* redeemClaudeReset({ - result: "already_used", - usageFailsAfterClaim: true, - }); - expect(outcome).toMatchObject({ _tag: "Success", success: "alreadyRedeemed" }); - }), - ); - - it.live( - "shadows instances whose driver is not registered in this build without failing boot", - () => - Effect.gen(function* () { - const codexId = ProviderInstanceId.make("codex_main"); - const ghostId = ProviderInstanceId.make("ghost_main"); - - const configMap: ProviderInstanceConfigMap = { - [codexId]: { - driver: ProviderDriverKind.make("codex"), - enabled: false, - config: makeCodexConfig({}), - }, - [ghostId]: { - driver: ProviderDriverKind.make("ghostDriver"), - displayName: "A fork-only driver we don't ship", - enabled: false, - config: { arbitrary: "payload", preserved: true }, - }, - }; - - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap, - }); - - const instances = yield* registry.listInstances; - expect(instances).toHaveLength(1); - expect(instances[0]!.instanceId).toBe(codexId); - - const unavailable = yield* registry.listUnavailable; - expect(unavailable).toHaveLength(1); - const ghost = unavailable[0]!; - expect(ghost.instanceId).toBe(ghostId); - expect(ghost.driver).toBe("ghostDriver"); - expect(ghost.availability).toBe("unavailable"); - expect(ghost.unavailableReason).toMatch(/ghostDriver/); - }).pipe(Effect.provide(layerTest)), - ); -}); - -describe("ProviderInstanceRegistryLive — 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 - // `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 - // surfaced; that merged layer then provides `ServerConfig.layerTest`'s - // `FileSystem` dep while keeping everything else surfaced to the test. - const layerInfra = OpenCodeRuntime.layer.pipe( - Layer.provide(OpenCodeServerLedger.layerTest), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge( - Layer.mock(CodexInstallation.CodexInstallation)({ - managedDirectory: "unused-managed-installation", - }), - ), - Layer.provideMerge( - Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { - getEnvironmentId: Effect.succeed( - EnvironmentId.make("00000000-0000-4000-8000-000000000001"), - ), - }), - ), - ); - const layerBase = AntigravityInstallation.AntigravityInstallation.layer.pipe( - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "provider-instance-registry-all-drivers-test", - }), - ), - Layer.provideMerge(layerInfra), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge(layerTestHttpClient), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - ); - const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( - Layer.provideMerge(layerBase), - ); - - it.live("boots one instance of every shipped driver from a single config map", () => - Effect.gen(function* () { - const codexId = ProviderInstanceId.make("codex_default"); - const claudeId = ProviderInstanceId.make("claude_default"); - const cursorId = ProviderInstanceId.make("cursor_default"); - const grokId = ProviderInstanceId.make("grok_default"); - const openCodeId = ProviderInstanceId.make("opencode_default"); - - const codexDriverKind = ProviderDriverKind.make("codex"); - const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); - const cursorDriverKind = ProviderDriverKind.make("cursor"); - const grokDriverKind = ProviderDriverKind.make("grok"); - const openCodeDriverKind = ProviderDriverKind.make("opencode"); - - const configMap: ProviderInstanceConfigMap = { - [codexId]: { - driver: codexDriverKind, - displayName: "Codex", - enabled: false, - config: makeCodexConfig({ homePath: "/home/julius/.codex" }), - }, - [claudeId]: { - driver: claudeDriverKind, - displayName: "Claude", - enabled: false, - config: makeClaudeConfig({ - homePath: "/home/julius/.claude-work", - launchArgs: "--verbose", - }), - }, - [cursorId]: { - driver: cursorDriverKind, - displayName: "Cursor", - enabled: false, - config: makeCursorConfig({}), - }, - [grokId]: { - driver: grokDriverKind, - displayName: "Grok", - enabled: false, - config: makeGrokConfig({}), - }, - [openCodeId]: { - driver: openCodeDriverKind, - displayName: "OpenCode", - enabled: false, - config: makeOpenCodeConfig({}), - }, - }; - - const { registry } = yield* makeProviderInstanceRegistry< - CodexDriverEnv | ClaudeDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv - >({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], - configMap, - }); - - // Every configured instance must materialize — none downgraded to a - // shadow snapshot, because every driver in the map is registered. - const unavailable = yield* registry.listUnavailable; - expect(unavailable).toEqual([]); - - const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); - expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), - ); - - // Instance lookup by id resolves each instance to its own bundle — - // this is how rest-of-server routes turn/session calls in the new - // model. Each driver's bundle carries its advertised `driverKind`. - const codex = yield* registry.getInstance(codexId); - const claude = yield* registry.getInstance(claudeId); - const cursor = yield* registry.getInstance(cursorId); - const grok = yield* registry.getInstance(grokId); - const openCode = yield* registry.getInstance(openCodeId); - expect(codex?.driverKind).toBe(codexDriverKind); - expect(claude?.driverKind).toBe(claudeDriverKind); - expect(cursor?.driverKind).toBe(cursorDriverKind); - expect(grok?.driverKind).toBe(grokDriverKind); - expect(openCode?.driverKind).toBe(openCodeDriverKind); - expect(codex?.displayName).toBe("Codex"); - expect(claude?.displayName).toBe("Claude"); - expect(cursor?.displayName).toBe("Cursor"); - expect(grok?.displayName).toBe("Grok"); - expect(openCode?.displayName).toBe("OpenCode"); - - // Every instance owns its own set of closures — no sharing across - // drivers. `orchestrationAdapter` / `textGeneration` / `snapshot` are all - // distinct references even when two instances happen to share a - // trait (e.g. Cursor + others all use a stub-or-real - // `textGeneration`; they must still be different object values). - const adapters = [ - codex!.orchestrationAdapter, - claude!.orchestrationAdapter, - cursor!.orchestrationAdapter, - grok!.orchestrationAdapter, - openCode!.orchestrationAdapter, - ]; - expect(new Set(adapters).size).toBe(adapters.length); - const textGenerations = [ - codex!.textGeneration, - claude!.textGeneration, - cursor!.textGeneration, - grok!.textGeneration, - openCode!.textGeneration, - ]; - expect(new Set(textGenerations).size).toBe(textGenerations.length); - const snapshots = [ - codex!.snapshot, - claude!.snapshot, - cursor!.snapshot, - grok!.snapshot, - openCode!.snapshot, - ]; - expect(new Set(snapshots).size).toBe(snapshots.length); - - // Snapshots identify themselves by `instanceId` + `driver` so - // downstream aggregation in `ProviderRegistry` can tell instances - // apart even when two share a driver. With `enabled: false`, the - // check short-circuits and we get a disabled/pending snapshot back - // — that's enough signal to validate the stamping wrapper without - // spawning real binaries. - const codexSnapshot = yield* codex!.snapshot.getSnapshot; - expect(codexSnapshot.instanceId).toBe(codexId); - expect(codexSnapshot.driver).toBe(codexDriverKind); - expect(codexSnapshot.enabled).toBe(false); - expect(codexSnapshot.continuation?.groupKey).toBe( - `codex:home:${(yield* Path.Path).resolve("/home/julius/.codex")}`, - ); - - const claudeSnapshot = yield* claude!.snapshot.getSnapshot; - expect(claudeSnapshot.instanceId).toBe(claudeId); - expect(claudeSnapshot.driver).toBe(claudeDriverKind); - expect(claudeSnapshot.enabled).toBe(false); - expect(claudeSnapshot.continuation?.groupKey).toBe( - `claude:home:${(yield* Path.Path).resolve("/home/julius/.claude-work")}`, - ); - - const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; - expect(cursorSnapshot.instanceId).toBe(cursorId); - expect(cursorSnapshot.driver).toBe(cursorDriverKind); - expect(cursorSnapshot.enabled).toBe(false); - expect(cursorSnapshot.continuation?.groupKey).toBe( - `${cursorDriverKind}:instance:${cursorId}`, - ); - - const grokSnapshot = yield* grok!.snapshot.getSnapshot; - expect(grokSnapshot.instanceId).toBe(grokId); - expect(grokSnapshot.driver).toBe(grokDriverKind); - expect(grokSnapshot.enabled).toBe(false); - expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); - - const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; - expect(openCodeSnapshot.instanceId).toBe(openCodeId); - expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); - expect(openCodeSnapshot.enabled).toBe(false); - expect(openCodeSnapshot.continuation?.groupKey).toBe( - `${openCodeDriverKind}:instance:${openCodeId}`, - ); - }).pipe(Effect.provide(layerTest)), - ); -}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts deleted file mode 100644 index 8830c618a2e2ca72dda60e88c3c569e449e36c2b..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts +++ /dev/null @@ -1,437 +0,0 @@ -/** - * ProviderInstanceRegistryLive — runtime implementation of - * `ProviderInstanceRegistry` plus its sibling mutator. - * - * Materializes every entry in a `ProviderInstanceConfigMap`: - * - * - When the entry's `driver` matches a registered driver, the registry - * decodes the opaque `config` envelope through `driver.configSchema` - * and calls `driver.create()` inside a fresh child scope. The - * resulting `ProviderInstance` is stored keyed by instance id, - * alongside its scope so the entry can be torn down independently. - * - When the entry's `driver` is unknown to this build (fork, rollback, - * in-flight PR branch), the registry emits an `"unavailable"` shadow - * `ServerProvider` snapshot instead of failing. This is what makes - * downgrades and fork-hopping safe per the - * `forward/backward compatibility invariant` in - * `packages/contracts/src/providerInstance.ts`. - * - When the entry's config fails schema decode, the registry logs and - * emits a shadow snapshot with the schema detail — same bucket as an - * unknown driver. - * - * Unlike the pre-Slice-D layer, the registry now holds mutable state - * (`Ref`s + `PubSub`) and exposes an internal mutator - * (`ProviderInstanceRegistryMutator`) whose `reconcile` method diffs a - * fresh config map against the live state, tearing down removed instances - * and building new ones without disturbing unaffected instances. - * - * Every live instance runs inside its own child `Scope`. The registry's - * own scope owns all child scopes via finalizers, so closing the registry - * tears every instance down in reverse order; closing a single instance - * (via `reconcile` removing it) leaves the rest untouched. - * - * @module provider/Layers/ProviderInstanceRegistryLive - */ -import { - providerInstanceConfigEnabledFlag, - ProviderInstanceId, - type ProviderInstanceConfig, - type ProviderInstanceConfigMap, - type ProviderDriverKind, - type ServerProvider, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Equal from "effect/Equal"; -import * as Exit from "effect/Exit"; -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 Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; - -import { buildUnavailableProviderSnapshot } from "../unavailableProviderSnapshot.ts"; -import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; -import * as ProviderInstanceRegistryMutator from "../Services/ProviderInstanceRegistryMutator.ts"; -import type { AnyProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; - -/** - * Live registry entry: the materialized `ProviderInstance` + the fresh - * child scope its `create` effect ran in + the original `entry` envelope - * so `reconcile` can cheaply detect "no-op" updates. - */ -interface LiveEntry { - readonly instance: ProviderInstance; - readonly scope: Scope.Closeable; - readonly entry: ProviderInstanceConfig; -} - -/** - * Internal state shared between the public registry service and the - * mutator service. Both services are thin shells around these refs. - */ -interface RegistryState { - readonly entries: Ref.Ref>; - readonly unavailable: Ref.Ref>; - readonly changes: PubSub.PubSub; -} - -/** - * Structural equality on `ProviderInstanceConfig` envelopes. Used by - * `reconcile` to skip rebuilds when settings arrive unchanged. Config - * payloads are opaque `unknown` at the envelope layer; `Equal.equals` - * falls back to structural equality for plain records, which matches how - * the schema decode output is constructed. - */ -const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => - Equal.equals(a, b); - -/** - * Resolve an entry's enabled state. An explicit false on either the - * envelope or the raw config blob wins (most restrictive) — old settings - * files can carry both flags with conflicting values, and a user's disable - * must never be silently undone. Otherwise the envelope flag wins, then the - * decoded config's flag (which carries the driver schema's default for - * built-ins and forks alike), then enabled by default. - */ -const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { - const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config); - if (entry.enabled === false || rawConfigEnabled === false) { - return false; - } - return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true; -}; - -/** - * Build one live entry from a raw config envelope. Returns either a - * `LiveEntry` plus undefined unavailable shadow, or a shadow snapshot and - * undefined entry — callers dispatch to the appropriate Ref bucket. - */ -const buildEntry = (input: { - readonly driversById: ReadonlyMap>; - readonly parentScope: Scope.Scope; - readonly instanceId: ProviderInstanceId; - readonly rawInstanceId: string; - readonly entry: ProviderInstanceConfig; -}): Effect.Effect< - | { readonly kind: "live"; readonly live: LiveEntry } - | { readonly kind: "unavailable"; readonly snapshot: ServerProvider }, - never, - R -> => - Effect.gen(function* () { - const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; - const driver = driversById.get(entry.driver); - if (!driver) { - return { - kind: "unavailable" as const, - snapshot: yield* buildUnavailableProviderSnapshot({ - driverKind: entry.driver, - instanceId, - displayName: entry.displayName, - accentColor: entry.accentColor, - reason: `Driver '${entry.driver}' is not registered in this build.`, - }), - }; - } - - 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); - yield* Effect.logError("Failed to decode provider instance config", { - instanceId: rawInstanceId, - driver: entry.driver, - detail, - }); - return { - kind: "unavailable" as const, - snapshot: yield* buildUnavailableProviderSnapshot({ - driverKind: entry.driver, - instanceId, - displayName: entry.displayName, - accentColor: entry.accentColor, - reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, - }), - }; - } - - const typedConfig = decodeResult.success; - const childScope = yield* Scope.make(); - // Attach the child scope to the registry's parent scope: if the - // registry scope closes, each surviving instance's child scope is - // closed through this finalizer. `reconcile` manually closes the - // child scope on remove/replace; subsequent close via the parent's - // finalizer is a no-op because `Scope.close` is idempotent. - yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); - - const createResult = yield* driver - .create({ - instanceId, - displayName: entry.displayName, - accentColor: entry.accentColor, - environment: entry.environment ?? [], - enabled: resolveEntryEnabled(entry, typedConfig), - config: typedConfig, - }) - .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); - if (createResult._tag === "Failure") { - yield* Effect.logError("Failed to create provider instance", { - instanceId: rawInstanceId, - driver: entry.driver, - detail: createResult.failure.detail, - }); - yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); - return { - kind: "unavailable" as const, - snapshot: yield* buildUnavailableProviderSnapshot({ - driverKind: entry.driver, - instanceId, - displayName: entry.displayName, - accentColor: entry.accentColor, - reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, - }), - }; - } - - return { - kind: "live" as const, - live: { - instance: createResult.success, - scope: childScope, - entry, - }, - }; - }); - -/** - * Reconcile-only implementation of the mutator. Exposed to the hydration - * layer; never called directly by the rest of the server. - */ -const makeReconcile = (input: { - readonly state: RegistryState; - readonly driversById: ReadonlyMap>; - readonly parentScope: Scope.Scope; -}): ((configMap: ProviderInstanceConfigMap) => Effect.Effect) => { - const { state, driversById, parentScope } = input; - return (configMap: ProviderInstanceConfigMap) => - Effect.gen(function* () { - const previousEntries = yield* Ref.get(state.entries); - const previousUnavailable = yield* Ref.get(state.unavailable); - const nextRaw = Object.entries(configMap); - const nextKeys = new Set( - nextRaw.map(([raw]) => ProviderInstanceId.make(raw)), - ); - - // 1. Close scopes for instances that disappeared or whose config - // changed. Do this BEFORE creating replacements so ids map 1-to-1 - // to live scopes at all times. - const removedIds: Array = []; - const replacedIds = new Set(); - for (const [instanceId, live] of previousEntries) { - if (!nextKeys.has(instanceId)) { - removedIds.push(instanceId); - continue; - } - const nextEntry = configMap[instanceId]; - if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) { - replacedIds.add(instanceId); - } - } - for (const id of [...removedIds, ...replacedIds]) { - const live = previousEntries.get(id); - if (live) { - yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore); - } - } - - // 2. Build additions and replacements. Walk `nextRaw` so the final - // entry order follows settings-author order. - const builtEntries = new Map(); - const builtUnavailable = new Map(); - let orderChanged = false; - const previousOrder = [...previousEntries.keys()]; - const nextOrder: Array = []; - - for (const [rawInstanceId, entry] of nextRaw) { - const instanceId = ProviderInstanceId.make(rawInstanceId); - nextOrder.push(instanceId); - - const existing = previousEntries.get(instanceId); - if (existing !== undefined && !replacedIds.has(instanceId)) { - // No-op update: keep the existing live entry and scope. - builtEntries.set(instanceId, existing); - continue; - } - - const result = yield* buildEntry({ - driversById, - parentScope, - instanceId, - rawInstanceId, - entry, - }); - if (result.kind === "live") { - builtEntries.set(instanceId, result.live); - } else { - builtUnavailable.set(instanceId, result.snapshot); - } - } - - if (previousOrder.length === nextOrder.length) { - for (let i = 0; i < previousOrder.length; i++) { - if (previousOrder[i] !== nextOrder[i]) { - orderChanged = true; - break; - } - } - } else { - orderChanged = true; - } - - const entriesChanged = - orderChanged || - removedIds.length > 0 || - replacedIds.size > 0 || - builtEntries.size !== previousEntries.size; - const unavailableChanged = - builtUnavailable.size !== previousUnavailable.size || - [...builtUnavailable].some(([id, snapshot]) => { - const prev = previousUnavailable.get(id); - return prev === undefined || !Equal.equals(prev, snapshot); - }) || - [...previousUnavailable].some(([id]) => !builtUnavailable.has(id)); - - yield* Ref.set(state.entries, builtEntries); - yield* Ref.set(state.unavailable, builtUnavailable); - - if (entriesChanged || unavailableChanged) { - yield* PubSub.publish(state.changes, undefined); - } - }); -}; - -/** - * Build the registry's runtime state from a concrete configMap. Returns a - * record containing: - * - * - `registry`: the read-only `ProviderInstanceRegistryShape` to expose - * under `ProviderInstanceRegistry`. - * - `mutator`: the `ProviderInstanceRegistryMutatorShape` to expose - * under `ProviderInstanceRegistryMutator`. - * - `reconcile`: the raw reconcile function, provided for convenience so - * boot-time layers can hydrate an initial map before publishing the - * services. - * - * The scope that this effect runs in owns every per-instance child scope - * created during `reconcile`. Closing that scope closes every live - * instance. - */ -export const makeProviderInstanceRegistry = (input: { - readonly drivers: ReadonlyArray>; - readonly configMap: ProviderInstanceConfigMap; -}): Effect.Effect< - { - readonly registry: ProviderInstanceRegistry.ProviderInstanceRegistryShape; - readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape; - }, - never, - R | Scope.Scope -> => - Effect.gen(function* () { - const driversById = new Map>( - input.drivers.map((driver) => [driver.driverKind, driver]), - ); - - // Capture the enclosing scope so per-instance child scopes can be - // attached to it at `reconcile` time. Without this, `reconcile` - // called later (e.g. from the hydration layer) would attach child - // scopes to the *caller's* scope instead of the registry's. - const parentScope = yield* Scope.Scope; - - // Capture the driver R context at construction time so `reconcile` - // can be invoked later without re-providing driver dependencies. - // The service tag's declared `reconcile: Effect` hides R from - // consumers — we materialize that here. - const driverContext = yield* Effect.context(); - - const entries = yield* Ref.make>(new Map()); - const unavailable = yield* Ref.make>(new Map()); - const changes = yield* PubSub.unbounded(); - yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); - - const state: RegistryState = { entries, unavailable, changes }; - const reconcileWithR = makeReconcile({ state, driversById, parentScope }); - const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["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: ProviderInstanceRegistry.ProviderInstanceRegistryShape = { - getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), - listInstances: Ref.get(entries).pipe( - Effect.map( - (map) => - Array.from(map.values(), (live) => live.instance) as ReadonlyArray, - ), - ), - listUnavailable: Ref.get(unavailable).pipe( - Effect.map((map) => Array.from(map.values()) as ReadonlyArray), - ), - // Getters: each read constructs a fresh Stream / Effect descriptor - // so multiple consumers don't share a single already-started - // Channel or subscription. Matches the pattern `ProviderRegistry` - // uses for its own `streamChanges`. - get streamChanges() { - return Stream.fromPubSub(changes); - }, - // Synchronous subscribe — callers that need to consume changes - // from a forked fibre must acquire the subscription in their own - // fibre first (via `yield* registry.subscribeChanges`) and only - // then fork a consumer loop on `Stream.fromSubscription(...)` / - // `PubSub.take(...)`. See the shape docs for the race this avoids. - get subscribeChanges() { - return PubSub.subscribe(changes); - }, - }; - - const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = { - reconcile, - }; - - return { registry, mutator }; - }); - -/** - * Layer variant that also exposes the mutator tag. Consumed by - * `ProviderInstanceRegistryHydrationLive` to reconcile on settings - * changes. Tests that exercise the mutator directly can pair this Layer - * with a test-local `ServerSettingsService`. - */ -export const layer = (input: { - readonly drivers: ReadonlyArray>; - readonly configMap: ProviderInstanceConfigMap; -}): Layer.Layer< - | ProviderInstanceRegistry.ProviderInstanceRegistry - | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, - never, - R -> => - Layer.effectContext( - makeProviderInstanceRegistry(input).pipe( - Effect.map(({ registry, mutator }) => - Context.make(ProviderInstanceRegistry.ProviderInstanceRegistry, registry).pipe( - Context.add(ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, mutator), - ), - ), - ), - ) as Layer.Layer< - | ProviderInstanceRegistry.ProviderInstanceRegistry - | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, - never, - R - >; diff --git a/apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts deleted file mode 100644 index 369e06fad896e3ac741a0f3a21495a3e2e65c7fe..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as Layer from "effect/Layer"; - -import * as ClaudeAdapterV2 from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; -import * as CodexAdapterV2 from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; -import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; -import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; -import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderContinuationRequests.ts"; - -export type ProviderOrchestrationAdapterInfrastructure = - | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner - | CodexAdapterV2.CodexAppServerClientFactory - | CursorAgentSdk.CursorAgentSdkRunner - | IdAllocator.IdAllocatorV2; - -/** - * Infrastructure shared by the V2 adapters materialized inside provider - * instances. `providerContinuationRequestsLayer` must be the same layer - * reference the orchestration runtime provides to its continuation worker so - * Effect layer memoization yields one shared queue. - */ -export const layer = Layer.mergeAll( - ClaudeAdapterV2.layerQueryRunner, - CodexAdapterV2.layerAppServerClientFactory, - CursorAgentSdk.layer, - IdAllocator.layer, - ProviderContinuationRequests.layer, -); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts deleted file mode 100644 index d767372fc1600a9317f0ca3cc4a7d2318cb4d227..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ /dev/null @@ -1,3338 +0,0 @@ -import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; -import * as CodexInstallation from "../CodexInstallation.ts"; -import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, it, assert } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Path from "effect/Path"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -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 Scope from "effect/Scope"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import * as CodexErrors from "effect-codex-app-server/errors"; -import { - EnvironmentId, - ClaudeSettings, - CodexSettings, - DEFAULT_SERVER_SETTINGS, - ProviderDriverKind, - ProviderInstanceId, - ServerSettings, - type ServerProvider, - type ServerProviderSlashCommand, - type ServerSettings as ContractServerSettings, -} from "@t3tools/contracts"; -import * as PlatformError from "effect/PlatformError"; -import { HttpClient, HttpClientResponse } from "effect/http"; -import { ChildProcessSpawner } from "effect/process"; -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 BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import * as AntigravityInstallation from "../AntigravityInstallation.ts"; -import * as ModelManifest from "../ModelManifest.ts"; -import { applyProviderCompatibility } from "../providerCompatibility.ts"; -import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; -import * as OpenCodeRuntime from "../opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; -import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; -import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts"; -import { - mergeProviderSnapshot, - mergeProviderSnapshots, - selectProvidersByKind, - upsertProviderWorkspaceSnapshot, -} from "./ProviderRegistry.ts"; -import * as ProviderRegistryLayer from "./ProviderRegistry.ts"; -import * as ServerConfig from "../../config.ts"; -import * as ServerSettingsModule from "../../serverSettings.ts"; -import { - readProviderStatusCache, - resolveProviderStatusCachePath, - writeProviderStatusCache, -} from "../providerStatusCache.ts"; -import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; -import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; -import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; -import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; -const decodeServerSettings = Schema.decodeSync(ServerSettings); -const encodeServerSettings = Schema.encodeSync(ServerSettings); -const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); - -const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); -const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); -const decodeCodexSettings = Schema.decodeSync(CodexSettings); -const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ - enabled: false, -}); - -process.env.T3CODE_CURSOR_ENABLED = "1"; - -// ── Test helpers ──────────────────────────────────────────────────── - -const encoder = new TextEncoder(); -const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); -const withBundledCompatibility = (snapshot: ServerProvider) => - applyProviderCompatibility( - snapshot, - undefined, - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - ); - -// Provider metadata checks use a bundled manifest and stubbed HTTP. -const layerTestHttpClient = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), - ), -).pipe(Layer.provideMerge(ModelManifest.layerTest)); - -const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - reportClientActivity: () => Effect.void, - removeRpcClient: () => Effect.void, - reportHostPowerState: () => Effect.void, - snapshot: Effect.succeed({ - hostPower: { - source: "unknown", - idle: "unknown", - idleSeconds: null, - locked: "unknown", - suspended: false, - onBattery: "unknown", - lowPowerMode: "unknown", - thermalState: "unknown", - stale: true, - updatedAt: TEST_EPOCH, - }, - leases: [], - activeForegroundLeaseCount: 0, - activeScopeKeys: [], - shouldRunOpportunisticWork: true, - updatedAt: TEST_EPOCH, - }), - streamChanges: Stream.empty, - hasDemand: () => Effect.succeed(true), - shouldRunScopeWork: () => Effect.succeed(true), - shouldRunOpportunisticWork: Effect.succeed(true), -}); - -function selectDescriptor( - id: string, - label: string, - options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, -) { - return { - id, - label, - type: "select" as const, - options: [...options], - ...(options.find((option) => option.isDefault)?.id - ? { currentValue: options.find((option) => option.isDefault)?.id } - : {}), - }; -} - -function booleanDescriptor(id: string, label: string) { - return { - id, - label, - type: "boolean" as const, - }; -} - -type TestClaudeCapabilities = { - readonly email: string | undefined; - readonly subscriptionType: string | undefined; - readonly tokenSource: string | undefined; - readonly apiProvider: string | undefined; - readonly slashCommands: ReadonlyArray; -}; - -function claudeCapabilities(overrides: Partial = {}) { - return () => - Effect.succeed({ - email: undefined, - subscriptionType: undefined, - tokenSource: undefined, - apiProvider: undefined, - slashCommands: [], - ...overrides, - }); -} - -const noClaudeCapabilities = () => - Effect.sync(() => undefined as TestClaudeCapabilities | undefined); - -function mockHandle(result: { stdout: string; stderr: string; code: number }) { - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make(encoder.encode(result.stdout)), - stderr: Stream.make(encoder.encode(result.stderr)), - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); -} - -function layerMockSpawner( - handler: (args: ReadonlyArray) => { - stdout: string; - stderr: string; - code: number; - }, -) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { args: ReadonlyArray }; - return Effect.succeed(mockHandle(handler(cmd.args))); - }), - ); -} - -function recordingMockSpawnerLayer( - handler: (args: ReadonlyArray) => { - stdout: string; - stderr: string; - code: number; - }, -) { - const commands: Array<{ - readonly args: ReadonlyArray; - readonly env: NodeJS.ProcessEnv | undefined; - }> = []; - const layer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { - args: ReadonlyArray; - options?: { - readonly env?: NodeJS.ProcessEnv; - }; - }; - commands.push({ args: cmd.args, env: cmd.options?.env }); - return Effect.succeed(mockHandle(handler(cmd.args))); - }), - ); - return { layer, commands }; -} - -function layerMockCommandSpawner( - handler: ( - command: string, - args: ReadonlyArray, - ) => { stdout: string; stderr: string; code: number }, -) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { - command: string; - args: ReadonlyArray; - }; - return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); - }), - ); -} - -function layerFailingSpawner(description: string) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description, - }), - ), - ), - ); -} - -function layerHangingScopedSpawner(killCalls: Ref.Ref) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const handle = ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.never, - isRunning: Effect.succeed(true), - kill: () => Ref.update(killCalls, (current) => current + 1), - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.never, - stderr: Stream.never, - all: Stream.never, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ), - ); -} - -const codexModelCapabilities = createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoningEffort", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - { id: "low", label: "Low" }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - ], -}) satisfies NonNullable; - -function makeCodexProbeSnapshot( - input: Partial = {}, -): CodexAppServerProviderSnapshot { - return { - version: "1.0.0", - account: { - account: { - type: "chatgpt", - email: "test@example.com", - planType: "pro", - }, - requiresOpenaiAuth: false, - }, - models: [ - { - slug: "gpt-live-codex", - name: "GPT Live Codex", - isCustom: false, - capabilities: codexModelCapabilities, - }, - ], - skills: [], - ...input, - }; -} - -function makeMutableServerSettingsService( - initial: ContractServerSettings = DEFAULT_SERVER_SETTINGS, -) { - return Effect.gen(function* () { - const settingsRef = yield* Ref.make(initial); - const changes = yield* PubSub.unbounded(); - - return { - start: Effect.void, - ready: Effect.void, - getSettings: Ref.get(settingsRef), - updateSettings: (patch) => - Effect.gen(function* () { - const current = yield* Ref.get(settingsRef); - const next = applyServerSettingsPatch(current, patch); - encodeServerSettings(next); - yield* Ref.set(settingsRef, next); - yield* PubSub.publish(changes, next); - return next; - }), - updateProviderInstance: (mutation, patch = {}) => - Effect.gen(function* () { - const current = yield* Ref.get(settingsRef); - const next = ServerSettingsModule.applyProviderInstanceMutation( - applyServerSettingsPatch(current, patch), - mutation, - ); - encodeServerSettings(next); - yield* Ref.set(settingsRef, next); - yield* PubSub.publish(changes, next); - return next; - }), - withSettingsSnapshot: (use) => Ref.get(settingsRef).pipe(Effect.flatMap(use)), - get streamChanges() { - return Stream.fromPubSub(changes); - }, - get subscribeChanges() { - return PubSub.subscribe(changes).pipe( - Effect.map((subscription) => Stream.fromSubscription(subscription)), - ); - }, - } satisfies ServerSettingsModule.ServerSettingsService["Service"]; - }); -} - -// The registry writes the status cache and only then publishes the change, so -// a subscriber that sees `checkedAt` on the stream knows the file is on disk. -// Subscribed before the publish that triggers it; a spin on the file would -// race the write and lose on a slow host. -const awaitPersistedProvider = ( - registry: ProviderRegistry.ProviderRegistry["Service"], - checkedAt: string, -) => - registry.streamChanges.pipe( - Stream.filter((providers) => providers.some((provider) => provider.checkedAt === checkedAt)), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, - ); - -const layerTestNodeServices = Layer.mergeAll( - NodeServices.layer, - Layer.mock(CodexInstallation.CodexInstallation)({ - managedDirectory: "unused-managed-installation", - }), - Layer.mock(ServerSecretStore.ServerSecretStore)({}), - Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { - getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), - }), -); - -it.layer( - Layer.mergeAll(layerTestNodeServices, ServerSettingsModule.layerTest(), layerTestHttpClient), -)("ProviderRegistry", (it) => { - describe("checkCodexProviderStatus", () => { - it.effect("uses the app-server account and model list for provider status", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - skills: [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ], - }), - ), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.version, "1.0.0"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "chatgpt"); - assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); - assert.strictEqual(status.auth.email, "test@example.com"); - assert.deepStrictEqual(status.models, [ - { - slug: "gpt-live-codex", - name: "GPT Live Codex", - isCustom: false, - capabilities: codexModelCapabilities, - }, - ]); - assert.deepStrictEqual(status.skills, [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ]); - assert.deepStrictEqual(status.slashCommands.slice(1), [ - { - name: "goal", - description: "Set a goal Codex keeps working toward until it is done", - input: { hint: "Objective, or pause, resume, clear" }, - }, - { - name: "feedback", - description: "Send this thread and Codex logs to OpenAI", - input: { hint: "Describe the issue (optional)" }, - }, - ]); - }), - ); - - it.effect("passes configured launch args to the Codex provider probe", () => - Effect.gen(function* () { - let observedLaunchArgs: string | undefined; - const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); - - const status = yield* checkCodexProviderStatus(settings, (input) => { - observedLaunchArgs = input.launchArgs; - return Effect.succeed(makeCodexProbeSnapshot()); - }); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); - }), - ); - - it.effect("returns unauthenticated when app-server requires OpenAI auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: null, - requiresOpenaiAuth: true, - }, - }), - ), - ); - - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.auth.status, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }), - ); - - it.effect("returns ready with unknown auth when app-server does not require OpenAI auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: null, - requiresOpenaiAuth: false, - }, - }), - ), - ); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "unknown"); - }), - ); - - it.effect("returns an api key label for codex api key auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: { type: "apiKey" }, - requiresOpenaiAuth: false, - }, - }), - ), - ); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "OpenAI API Key"); - }), - ); - - it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: { type: "amazonBedrock" }, - requiresOpenaiAuth: false, - }, - }), - ), - ); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "amazonBedrock"); - assert.strictEqual(status.auth.label, "Amazon Bedrock"); - }), - ); - - it.effect.each([ - "codex", - "/Applications/Custom App.app/Contents/Resources/codex", - "C:\\Tools\\codex.exe", - ])("explains how to configure a Codex executable that cannot start: %s", (binaryPath) => - Effect.gen(function* () { - const settings = { ...defaultCodexSettings, binaryPath }; - const status = yield* checkCodexProviderStatus(settings, (input) => { - assert.strictEqual(input.binaryPath, binaryPath); - return Effect.fail( - new CodexErrors.CodexAppServerSpawnError({ - command: `${binaryPath} app-server`, - cause: new Error("spawn ENOENT"), - }), - ); - }); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.include(status.message, binaryPath); - assert.include(status.message, "Settings → Providers → Codex → Binary path on the server"); - assert.strictEqual( - status.message?.includes("Installing ChatGPT or Codex desktop"), - binaryPath === "codex", - ); - assert.strictEqual(settings.binaryPath, binaryPath); - }), - ); - - it.effect("closes the app-server probe scope when provider status times out", () => - Effect.gen(function* () { - const killCalls = yield* Ref.make(0); - const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( - Effect.provide(layerHangingScopedSpawner(killCalls)), - Effect.forkChild, - ); - - yield* Effect.yieldNow; - yield* TestClock.adjust("11 seconds"); - yield* Effect.yieldNow; - - const status = yield* Fiber.join(statusFiber); - assert.strictEqual(status.status, "error"); - assert.strictEqual( - status.message, - "Timed out while checking Codex app-server provider status.", - ); - assert.strictEqual(yield* Ref.get(killCalls), 1); - }), - ); - }); - - describe("ProviderRegistryLive", () => { - it("stores workspace skills and commands without changing machine metadata", () => { - const provider = { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [{ name: "global" }], - skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], - } satisfies ServerProvider; - const scopedSnapshot = { - ...provider, - checkedAt: "2026-03-25T00:01:00.000Z", - slashCommands: [{ name: "project" }], - skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }], - } satisfies ServerProvider; - - const result = upsertProviderWorkspaceSnapshot(provider, "/project", scopedSnapshot); - - assert.deepStrictEqual(result.slashCommands, provider.slashCommands); - assert.deepStrictEqual(result.skills, provider.skills); - assert.deepStrictEqual(result.workspaceSnapshots, [ - { - cwd: "/project", - checkedAt: scopedSnapshot.checkedAt, - slashCommands: scopedSnapshot.slashCommands, - skills: scopedSnapshot.skills, - }, - ]); - - const pendingSnapshot = { - ...scopedSnapshot, - slashCommands: [COMPACT_SLASH_COMMAND], - slashCommandsPending: true, - } satisfies ProviderWorkspaceSnapshot; - const partial = upsertProviderWorkspaceSnapshot(result, "/project", pendingSnapshot); - assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.slashCommands, [{ name: "project" }]); - assert.strictEqual(partial.workspaceSnapshots?.[0]?.slashCommandsPending, true); - const otherProject = upsertProviderWorkspaceSnapshot( - partial, - "/other-project", - pendingSnapshot, - ); - assert.deepStrictEqual(otherProject.workspaceSnapshots?.[1]?.slashCommands, [ - COMPACT_SLASH_COMMAND, - ]); - const recovered = upsertProviderWorkspaceSnapshot(partial, "/project", { - ...scopedSnapshot, - slashCommands: [COMPACT_SLASH_COMMAND, { name: "replacement" }], - }); - assert.deepStrictEqual(recovered.workspaceSnapshots?.[0]?.slashCommands, [ - COMPACT_SLASH_COMMAND, - { name: "replacement" }, - ]); - assert.strictEqual(recovered.workspaceSnapshots?.[0]?.slashCommandsPending, undefined); - }); - - it("preserves previously discovered provider models when a refresh returns none", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - slashCommands: [{ name: "review", description: "Review changes" }], - skills: [ - { - name: "typescript", - description: "TypeScript help", - path: "/skills/typescript/SKILL.md", - enabled: true, - }, - ], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - slashCommands: [], - skills: [], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, - [], - ); - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).skills, []); - }); - - it("drops custom models the refreshed snapshot no longer carries", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: ProviderDriverKind.make("claudeAgent"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2.1.0", - models: [ - { - slug: "claude-sonnet-4-6", - name: "Sonnet 4.6", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-custom", - name: "removed-custom", - isCustom: true, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [previousProvider.models[0]], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...refreshedProvider.models, - ]); - }); - - it("drops stale ACP Registry models missing from a completed discovery probe", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("acpRegistry_codex"), - driver: ProviderDriverKind.make("acpRegistry"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-08-13T00:00:00.000Z", - version: "1.2.0", - models: [ - { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, - { - slug: "gpt-5.6-sol[low]", - name: "GPT-5.6-Sol (low)", - isCustom: false, - capabilities: null, - }, - { slug: "default", name: "Default", isCustom: false, capabilities: null }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-08-13T00:01:00.000Z", - models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...refreshedProvider.models, - ]); - }); - - it("retains ACP Registry models while discovery has not completed", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("acpRegistry_codex"), - driver: ProviderDriverKind.make("acpRegistry"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-08-13T00:00:00.000Z", - version: "1.2.0", - models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const checkingProvider = { - ...previousProvider, - checkedAt: "2026-08-13T00:01:00.000Z", - auth: { status: "unknown" }, - models: [{ slug: "default", name: "Default", isCustom: false, capabilities: null }], - } satisfies ServerProvider; - const failedProbeProvider = { - ...checkingProvider, - status: "warning", - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, checkingProvider).models, [ - { slug: "default", name: "Default", isCustom: false, capabilities: null }, - { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, - ]); - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, failedProbeProvider).models, [ - { slug: "default", name: "Default", isCustom: false, capabilities: null }, - { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, - ]); - }); - - it("drops stale OpenCode models missing from a successful refresh", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-07-17T00:01:00.000Z", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...refreshedProvider.models, - ]); - }); - - it("retains stale OpenCode models when a refresh fails", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [{ name: "review", description: "Review changes" }], - skills: [ - { - name: "typescript", - description: "TypeScript help", - path: "/skills/typescript/SKILL.md", - enabled: true, - }, - ], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:01:00.000Z", - models: [], - message: "Failed to refresh OpenCode models.", - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, - previousProvider.slashCommands, - ); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, refreshedProvider).skills, - previousProvider.skills, - ); - }); - - it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [{ name: "review", description: "Review changes" }], - skills: [ - { - name: "typescript", - description: "TypeScript help", - path: "/skills/typescript/SKILL.md", - enabled: true, - }, - ], - } as const satisfies ServerProvider; - const pendingProvider = { - ...previousProvider, - status: "warning", - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:01:00.000Z", - version: null, - models: [], - message: "OpenCode provider status has not been checked in this session yet.", - } satisfies ServerProvider; - const loggedOutProvider = { - ...previousProvider, - status: "warning", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:02:00.000Z", - models: [], - slashCommands: [], - skills: [], - message: "OpenCode is available, but it did not report any connected upstream providers.", - } satisfies ServerProvider; - const missingProvider = { - ...previousProvider, - status: "error", - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:03:00.000Z", - version: null, - models: [], - message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", - } satisfies ServerProvider; - const authoritativeProvider = { - ...previousProvider, - checkedAt: "2026-07-17T00:04:00.000Z", - models: [previousProvider.models[0]!], - } satisfies ServerProvider; - const failedProvider = { - ...authoritativeProvider, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:05:00.000Z", - models: [], - message: "Failed to refresh OpenCode models.", - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ - ...previousProvider.models, - ]); - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, loggedOutProvider).models, []); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands, - [], - ); - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, loggedOutProvider).skills, []); - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); - - const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); - const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider); - - assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); - }); - - describe("Codex model inventories", () => { - const cachedProvider = { - instanceId: ProviderInstanceId.make("codex-personal"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-09-04T19:00:00.000Z", - version: "0.153.3", - models: ["vega-alpha", "joule-alpha", "kindle-alpha", "ultima-alpha", "solstice-alpha"].map( - (slug) => ({ slug, name: slug, isCustom: false, capabilities: null }), - ), - slashCommands: [], - skills: [], - } satisfies ServerProvider; - const customModel = { - slug: "custom-model", - name: "Custom model", - isCustom: true, - capabilities: null, - } as const; - const refreshedProvider = { - ...cachedProvider, - checkedAt: "2026-09-04T19:01:00.000Z", - models: [ - { slug: "gpt-6-astra", name: "GPT 6 Astra", isCustom: false, capabilities: null }, - cachedProvider.models[0]!, - customModel, - ], - } satisfies ServerProvider; - const pendingProvider = { - ...cachedProvider, - status: "warning", - installed: false, - auth: { status: "unknown" }, - models: [customModel], - } satisfies ServerProvider; - const failedProvider = { - ...pendingProvider, - checkedAt: "2026-09-04T19:02:00.000Z", - status: "error", - installed: true, - } satisfies ServerProvider; - - it("drops retired alpha models after discovery, including without OpenAI authentication", () => { - for (const authStatus of ["authenticated", "unknown"] as const) { - assert.deepStrictEqual( - mergeProviderSnapshot(cachedProvider, { - ...refreshedProvider, - auth: { status: authStatus }, - }).models, - refreshedProvider.models, - ); - } - }); - - it("keeps discovered models during startup and failed probes without restoring removed custom models", () => { - for (const provider of [pendingProvider, failedProvider]) { - assert.deepStrictEqual( - mergeProviderSnapshot( - { - ...cachedProvider, - models: [...cachedProvider.models, { ...customModel, slug: "removed-custom" }], - }, - provider, - ).models, - [customModel, ...cachedProvider.models], - ); - } - }); - - it("clears discovered models after sign-out, disable, uninstall, or empty discovery", () => { - const emptyProvider = { ...refreshedProvider, models: [customModel] }; - const clearedProviders = [ - { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, - { ...emptyProvider, status: "disabled", enabled: false }, - { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, - emptyProvider, - { ...emptyProvider, models: [] }, - ] satisfies ReadonlyArray; - - for (const provider of clearedProviders) { - assert.deepStrictEqual( - mergeProviderSnapshot(cachedProvider, provider).models, - provider.models, - ); - } - }); - - it.effect("persists removals across failed refreshes and registry restarts", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const filePath = yield* resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - instanceId: cachedProvider.instanceId, - }); - yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); - const nextProvider = yield* Ref.make(refreshedProvider); - const instance = { - instanceId: cachedProvider.instanceId, - driverKind: cachedProvider.driver, - continuationIdentity: { - driverKind: cachedProvider.driver, - continuationKey: "codex:instance:codex-personal", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: cachedProvider.driver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(pendingProvider), - refresh: Ref.get(nextProvider), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (id) => - Effect.succeed(id === instance.instanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), - }, - ); - const retainedModels = [ - customModel, - ...refreshedProvider.models.filter((model) => !model.isCustom), - ]; - - for (const restarted of [false, true]) { - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const expectedModels = restarted - ? retainedModels - : [customModel, ...cachedProvider.models]; - assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, expectedModels); - - yield* registry.refreshInstance(instance.instanceId); - assert.deepStrictEqual( - (yield* readProviderStatusCache(filePath))?.models, - restarted ? retainedModels : refreshedProvider.models, - ); - - yield* Ref.set(nextProvider, failedProvider); - const afterFailure = yield* registry.refreshInstance(instance.instanceId); - assert.deepStrictEqual(afterFailure[0]?.models, retainedModels); - assert.deepStrictEqual( - (yield* readProviderStatusCache(filePath))?.models, - retainedModels, - ); - }).pipe( - Effect.provide( - ProviderRegistryLayer.layer.pipe(Layer.provide(layerInstanceRegistry)), - ), - Effect.scoped, - ); - } - }).pipe( - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-codex-retired-model-cache-", - }).pipe(Layer.provideMerge(NodeServices.layer)), - ), - ), - ); - }); - - describe("Antigravity model inventories", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("antigravity-personal"), - driver: ProviderDriverKind.make("antigravity"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-09-02T00:00:00.000Z", - version: "0.1.3", - models: [ - { - slug: "gemini-3.1-pro-high", - name: "Gemini 3.1 Pro High", - isCustom: false, - capabilities: null, - }, - { - slug: "gemini-3-flash", - name: "Gemini 3 Flash", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - - it("removes unavailable models after a successful refresh", () => { - for (const status of ["ready", "warning"] as const) { - const refreshedProvider = { - ...previousProvider, - status, - checkedAt: "2026-09-02T00:01:00.000Z", - models: [previousProvider.models[1]], - } satisfies ServerProvider; - const afterRefresh = mergeProviderSnapshot(previousProvider, refreshedProvider); - - assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models); - - const afterFailure = mergeProviderSnapshot(afterRefresh, { - ...refreshedProvider, - status: "error", - auth: { status: "unknown" }, - models: [], - }); - assert.deepStrictEqual(afterFailure.models, refreshedProvider.models); - } - }); - - it("keeps cached models during health checks and temporary failures", () => { - for (const installed of [false, true]) { - const pendingProvider = { - ...previousProvider, - status: "warning", - installed, - auth: { status: "unknown" }, - checkedAt: "2026-09-02T00:01:00.000Z", - version: installed ? previousProvider.version : null, - models: [], - } satisfies ServerProvider; - - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, pendingProvider).models, - previousProvider.models, - ); - } - - for (const authStatus of ["unknown", "authenticated"] as const) { - const failedProvider = { - ...previousProvider, - status: "error", - auth: { status: authStatus }, - checkedAt: "2026-09-02T00:02:00.000Z", - models: [], - } satisfies ServerProvider; - - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, failedProvider).models, - previousProvider.models, - ); - } - }); - - it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => { - const emptyProvider = { - ...previousProvider, - checkedAt: "2026-09-02T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - const clearedProviders = [ - { ...emptyProvider, status: "warning", auth: { status: "unauthenticated" } }, - { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, - { ...emptyProvider, status: "disabled", enabled: false }, - { ...emptyProvider, status: "error", enabled: false }, - { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, - emptyProvider, - ] satisfies ReadonlyArray; - - for (const provider of clearedProviders) { - const afterRemoval = mergeProviderSnapshot(previousProvider, provider); - assert.deepStrictEqual(afterRemoval.models, []); - - const afterFailure = mergeProviderSnapshot(afterRemoval, { - ...emptyProvider, - status: "error", - auth: { status: "unknown" }, - }); - assert.deepStrictEqual(afterFailure.models, []); - } - }); - }); - - describe("Antigravity saved account", () => { - const signedIn = { - instanceId: ProviderInstanceId.make("antigravity-personal"), - driver: ProviderDriverKind.make("antigravity"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated", type: "oauth-personal", label: "Google account" }, - checkedAt: "2026-09-05T00:00:00.000Z", - version: "agy_acp_server_1.1.1", - models: [ - { - slug: "gemini-3.7-flash-high", - name: "Gemini 3.7 Flash", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [{ name: "plan" }], - skills: [], - } as const satisfies ServerProvider; - const uncheckedMessage = - "Antigravity is installed. Google account access is not checked yet."; - const restartProbe = { - ...signedIn, - status: "warning", - auth: { status: "unknown" }, - checkedAt: "2026-09-05T00:01:00.000Z", - message: uncheckedMessage, - models: [], - } as const satisfies ServerProvider; - - it("keeps the saved Google account through restart health checks", () => { - const merged = mergeProviderSnapshot(signedIn, restartProbe); - const { message: _uncheckedMessage, ...probeWithoutMessage } = restartProbe; - assert.deepStrictEqual(merged, { - ...probeWithoutMessage, - status: "ready", - auth: signedIn.auth, - models: signedIn.models, - }); - assert.equal("message" in merged, false); - // The next periodic probe reads the merged snapshot as its previous state. - assert.deepStrictEqual(mergeProviderSnapshot(merged, restartProbe), merged); - }); - - it("carries the account through the boot probe and a failed probe without hiding them", () => { - const booting = { - ...restartProbe, - installed: false, - version: null, - message: "Checking Antigravity availability.", - } satisfies ServerProvider; - assert.deepStrictEqual(mergeProviderSnapshot(signedIn, booting), { - ...booting, - auth: signedIn.auth, - models: signedIn.models, - }); - - const failed = { - ...restartProbe, - status: "error", - message: "Antigravity did not respond to its local health check within 90 seconds.", - } satisfies ServerProvider; - assert.deepStrictEqual(mergeProviderSnapshot(signedIn, failed), { - ...failed, - auth: signedIn.auth, - models: signedIn.models, - }); - }); - - it("does not invent an account after sign-out, disable, uninstall, or for other providers", () => { - const untouched = [ - { ...restartProbe, auth: { status: "unauthenticated" } }, - { ...restartProbe, status: "disabled", enabled: false }, - { ...restartProbe, status: "error", installed: false }, - { ...restartProbe, driver: ProviderDriverKind.make("codex") }, - // The instance was rebuilt with another sign-in method. - { ...restartProbe, auth: { status: "unknown", type: "gemini-api-key" } }, - ] satisfies ReadonlyArray; - for (const next of untouched) { - const merged = mergeProviderSnapshot(signedIn, next); - assert.deepStrictEqual(merged.auth, next.auth); - assert.equal(merged.status, next.status); - assert.equal(merged.message, next.message); - } - assert.deepStrictEqual( - mergeProviderSnapshot({ ...signedIn, auth: { status: "unknown" } }, restartProbe).auth, - { status: "unknown" }, - ); - assert.equal( - mergeProviderSnapshot( - { ...signedIn, driver: ProviderDriverKind.make("codex") }, - restartProbe, - ).auth.status, - "unknown", - ); - assert.deepStrictEqual( - mergeProviderSnapshot(signedIn, { - ...restartProbe, - auth: { status: "unknown", type: "oauth-personal" }, - }).auth, - signedIn.auth, - ); - }); - }); - - it("fills missing capabilities from the previous provider snapshot", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [], - }), - }, - ], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - }); - - it.effect("does not run provider probes during layer construction", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const initialProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "warning", - enabled: true, - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: null, - message: "Checking Codex provider status.", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshCalls = yield* Ref.make(0); - const instance = { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(initialProvider), - refresh: Ref.update(refreshCalls, (count) => count + 1).pipe( - Effect.andThen(Effect.never), - ), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const layerInstanceRegistry = 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.subscribe), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLayer.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-background-refresh-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); - assert.strictEqual(yield* Ref.get(refreshCalls), 0); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => - Effect.gen(function* () { - const driver = ProviderDriverKind.make("codex"); - const instanceId = ProviderInstanceId.make("codex"); - const machineProvider = { - instanceId, - driver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [{ name: "global" }], - skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], - } as const satisfies ServerProvider; - const scopedProvider = { - ...machineProvider, - checkedAt: "2026-06-10T00:01:00.000Z", - slashCommands: [{ name: "project" }], - skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], - } as const satisfies ServerProvider; - const pendingScopedProvider = { - ...scopedProvider, - status: "error", - installed: false, - slashCommands: [], - } as const satisfies ServerProvider; - const snapshotCalls = yield* Ref.make(0); - const scopedResult = yield* Ref.make({ - ...scopedProvider, - status: "error", - slashCommands: [COMPACT_SLASH_COMMAND], - slashCommandsPending: true, - }); - const cacheInvalidations = yield* Ref.make(0); - const scanGate = yield* Ref.make<{ - readonly started: Deferred.Deferred; - readonly release: Deferred.Deferred; - } | null>(null); - const returnPendingSnapshot = yield* Ref.make(true); - const probeStarted = yield* Deferred.make(); - const releaseProbe = yield* Deferred.make(); - const makeInstance = ( - provider: ServerProvider, - snapshotForCwd: NonNullable, - ): ProviderInstance => ({ - instanceId, - driverKind: driver, - continuationIdentity: { - driverKind: driver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: driver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - snapshotForCwd, - invalidateCaches: Ref.update(cacheInvalidations, (count) => count + 1), - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }); - const firstInstance = makeInstance(machineProvider, () => - Effect.gen(function* () { - yield* Ref.update(snapshotCalls, (count) => count + 1); - if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; - yield* Deferred.succeed(probeStarted, undefined); - yield* Deferred.await(releaseProbe); - const result = yield* Ref.get(scopedResult); - const gate = yield* Ref.getAndSet(scanGate, null); - if (gate) { - yield* Deferred.succeed(gate.started, undefined); - yield* Deferred.await(gate.release); - } - return result; - }), - ); - const rebuiltProvider = { - ...machineProvider, - checkedAt: "2026-06-10T00:02:00.000Z", - status: "warning", - installed: false, - auth: { status: "unknown" }, - } satisfies ServerProvider; - const rebuiltInstance = makeInstance(rebuiltProvider, () => - Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), - ); - const registryChanges = yield* PubSub.unbounded(); - const instancesRef = yield* Ref.make>([firstInstance]); - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (requestedId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === requestedId), - ), - ), - listInstances: Ref.get(instancesRef), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(registryChanges), - subscribeChanges: PubSub.subscribe(registryChanges), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLayer.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-workspace-snapshot-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); - yield* Ref.set(returnPendingSnapshot, false); - const workspaceUpdate = yield* registry.streamChanges.pipe( - Stream.runHead, - Effect.forkChild, - ); - yield* Effect.yieldNow; - const firstRefresh = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); - yield* Deferred.await(probeStarted); - const duplicateRefresh = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); - yield* Effect.yieldNow; - assert.strictEqual(yield* Ref.get(snapshotCalls), 2); - yield* Deferred.succeed(releaseProbe, undefined); - yield* Fiber.join(firstRefresh); - yield* Fiber.join(duplicateRefresh); - const published = yield* Fiber.join(workspaceUpdate); - assert.strictEqual(published._tag, "Some"); - const providers = yield* registry.getProviders; - assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); - assert.deepStrictEqual( - providers[0]?.workspaceSnapshots?.[0]?.skills, - scopedProvider.skills, - ); - assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [ - COMPACT_SLASH_COMMAND, - ]); - assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true); - yield* Ref.set(scopedResult, { - ...scopedProvider, - status: "error", - slashCommandsPending: false, - }); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands, - scopedProvider.slashCommands, - ); - assert.strictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, - undefined, - ); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); - const newSkills = [ - ...scopedProvider.skills, - { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, - ]; - yield* Ref.set(scopedResult, { ...scopedProvider, skills: newSkills }); - yield* registry.refreshWorkspaceSnapshot({ - instanceId, - cwd: "/workspace", - fresh: true, - }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 4); - assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), - [newSkills], - ); - - // A slow fresh scan that read older files must not overwrite a - // newer scan that finished first. - const slowStarted = yield* Deferred.make(); - const releaseSlow = yield* Deferred.make(); - yield* Ref.set(scanGate, { started: slowStarted, release: releaseSlow }); - yield* Ref.set(scopedResult, scopedProvider); - const slowScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace", fresh: true }) - .pipe(Effect.forkChild); - yield* Deferred.await(slowStarted); - const latestSkills = [ - ...newSkills, - { name: "latest", path: "/workspace/latest/SKILL.md", enabled: true }, - ]; - yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills }); - yield* registry.refreshWorkspaceSnapshot({ - instanceId, - cwd: "/workspace", - fresh: true, - }); - yield* Deferred.succeed(releaseSlow, undefined); - yield* Fiber.join(slowScan); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), - [latestSkills], - ); - - yield* Ref.set(instancesRef, [rebuiltInstance]); - yield* PubSub.publish(registryChanges, undefined); - let rebuilt = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; - attempt += 1 - ) { - yield* Effect.yieldNow; - rebuilt = yield* registry.getProviders; - } - assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); - assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("refreshes OpenCode catalogs and preserves other providers", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const openCodeDriver = ProviderDriverKind.make("opencode"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const openCodeInstanceId = ProviderInstanceId.make("opencode"); - const codexRefreshCalls = yield* Ref.make(0); - const openCodeRefreshCalls = yield* Ref.make(0); - const codexProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const failedOpenCodeProvider = { - instanceId: openCodeInstanceId, - driver: openCodeDriver, - status: "error", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - message: "Failed to refresh OpenCode models.", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const recoveredOpenCodeProvider = { - ...failedOpenCodeProvider, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:01:00.000Z", - message: "One upstream provider connected through OpenCode.", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], - } as const satisfies ServerProvider; - const changedCatalogProvider = { - ...recoveredOpenCodeProvider, - checkedAt: "2026-06-10T00:02:00.000Z", - models: [ - { - slug: "anthropic/claude-sonnet-4", - name: "Claude Sonnet 4", - subProvider: "Anthropic", - isCustom: false, - capabilities: null, - }, - ], - } as const satisfies ServerProvider; - const catalogSnapshot = yield* Ref.make(recoveredOpenCodeProvider); - const instances = [ - { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(codexProvider), - refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe( - Effect.as(codexProvider), - ), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }, - { - instanceId: openCodeInstanceId, - driverKind: openCodeDriver, - continuationIdentity: { - driverKind: openCodeDriver, - continuationKey: "opencode:instance:opencode", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: openCodeDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(failedOpenCodeProvider), - refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe( - Effect.andThen(Ref.get(catalogSnapshot)), - ), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }, - ] satisfies ReadonlyArray; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), - listInstances: Effect.succeed(instances), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLayer.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-reconnect-refresh-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const initialProviders = yield* registry.getProviders; - assert.strictEqual( - initialProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.status, - "error", - ); - - const recoveredProviders = yield* registry.refresh(); - assert.deepStrictEqual( - recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId) - ?.models, - recoveredOpenCodeProvider.models, - ); - assert.deepStrictEqual( - recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), - withBundledCompatibility(codexProvider), - ); - - yield* Ref.set(catalogSnapshot, changedCatalogProvider); - const changedProviders = yield* registry.refresh(); - assert.deepStrictEqual( - changedProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.models, - changedCatalogProvider.models, - ); - assert.deepStrictEqual( - changedProviders.find((provider) => provider.instanceId === codexInstanceId), - withBundledCompatibility(codexProvider), - ); - }).pipe(Effect.provide(runtimeServices)); - - assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2); - assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2); - }), - ); - - it("persists merged provider snapshots for the providers that were refreshed", () => { - const previousProviders = [ - { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - const refreshedCursor = { - ...previousProviders[0], - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - - const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); - const persistedProviders = selectProvidersByKind( - mergedProviders, - new Set([ProviderDriverKind.make("cursor")]), - ); - - assert.deepStrictEqual(persistedProviders, [ - { - ...refreshedCursor, - models: [...previousProviders[0].models], - }, - ]); - }); - - it.effect("persists the merged snapshot when a live update has empty models", () => - Effect.gen(function* () { - const cursorDriver = ProviderDriverKind.make("cursor"); - const cursorInstanceId = ProviderInstanceId.make("cursor"); - const initialProvider = { - instanceId: cursorInstanceId, - driver: cursorDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - ], - }), - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...initialProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - const changes = yield* PubSub.unbounded(); - const instance = { - instanceId: cursorInstanceId, - driverKind: cursorDriver, - continuationIdentity: { - driverKind: cursorDriver, - continuationKey: "cursor:instance:cursor", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: cursorDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(initialProvider), - refresh: Effect.succeed(refreshedProvider), - streamChanges: Stream.fromPubSub(changes), - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLayer.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-merged-persist-", - }), - ), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const config = yield* ServerConfig.ServerConfig; - const filePath = yield* resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - instanceId: cursorInstanceId, - }); - - assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ - ...initialProvider.models, - ]); - const persisted = yield* awaitPersistedProvider(registry, refreshedProvider.checkedAt); - yield* PubSub.publish(changes, refreshedProvider); - yield* Fiber.join(persisted); - const cachedProvider = yield* readProviderStatusCache(filePath); - - assert.deepStrictEqual( - cachedProvider, - withBundledCompatibility({ - ...refreshedProvider, - models: [...initialProvider.models], - }), - ); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect( - "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", - () => - Effect.gen(function* () { - const openCodeDriver = ProviderDriverKind.make("opencode"); - const openCodeInstanceId = ProviderInstanceId.make("opencode"); - const initialProvider = { - instanceId: openCodeInstanceId, - driver: openCodeDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const authoritativeProvider = { - ...initialProvider, - checkedAt: "2026-07-17T00:01:00.000Z", - models: [initialProvider.models[0]!], - } satisfies ServerProvider; - const failedProvider = { - ...authoritativeProvider, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:02:00.000Z", - models: [], - message: "Failed to refresh OpenCode models.", - } satisfies ServerProvider; - const changes = yield* PubSub.unbounded(); - const instance = { - instanceId: openCodeInstanceId, - driverKind: openCodeDriver, - continuationIdentity: { - driverKind: openCodeDriver, - continuationKey: "opencode:instance:opencode", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: openCodeDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(initialProvider), - refresh: Effect.succeed(authoritativeProvider), - streamChanges: Stream.fromPubSub(changes), - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLayer.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-opencode-authoritative-persist-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const config = yield* ServerConfig.ServerConfig; - const filePath = yield* resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - instanceId: openCodeInstanceId, - }); - - const authoritativePersisted = yield* awaitPersistedProvider( - registry, - authoritativeProvider.checkedAt, - ); - yield* PubSub.publish(changes, authoritativeProvider); - yield* Fiber.join(authoritativePersisted); - let cachedProvider = yield* readProviderStatusCache(filePath); - - assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); - - const failedPersisted = yield* awaitPersistedProvider( - registry, - failedProvider.checkedAt, - ); - yield* PubSub.publish(changes, failedProvider); - yield* Fiber.join(failedPersisted); - cachedProvider = yield* readProviderStatusCache(filePath); - - assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); - assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ - authoritativeProvider.models[0]!, - ]); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("returns the cached provider list when a manual refresh fails", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const cachedProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const instance = { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(cachedProvider), - refresh: Effect.die(new Error("simulated refresh failure")), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const layerInstanceRegistry = 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 scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLayer.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-refresh-failure-", - }), - ), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - - assert.deepStrictEqual(yield* registry.getProviders, [ - withBundledCompatibility(cachedProvider), - ]); - assert.deepStrictEqual(yield* registry.refresh(codexDriver), [ - withBundledCompatibility(cachedProvider), - ]); - assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ - withBundledCompatibility(cachedProvider), - ]); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("keeps consuming registry changes after one sync fails", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const claudeDriver = ProviderDriverKind.make("claudeAgent"); - const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); - const codexProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const claudeProvider = { - instanceId: claudeInstanceId, - driver: claudeDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:01:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const makeInstance = (provider: ServerProvider): ProviderInstance => ({ - instanceId: provider.instanceId, - driverKind: provider.driver, - continuationIdentity: { - driverKind: provider.driver, - continuationKey: `${provider.driver}:instance:${provider.instanceId}`, - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: provider.driver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }); - const codexInstance = makeInstance(codexProvider); - const claudeInstance = makeInstance(claudeProvider); - const changes = yield* PubSub.unbounded(); - const instancesRef = yield* Ref.make>([codexInstance]); - const failNextList = yield* Ref.make(false); - const wait = () => Effect.yieldNow; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === instanceId), - ), - ), - listInstances: Effect.gen(function* () { - const shouldFail = yield* Ref.get(failNextList); - if (shouldFail) { - yield* Ref.set(failNextList, false); - return yield* Effect.die(new Error("simulated registry list failure")); - } - return yield* Ref.get(instancesRef); - }), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(changes), - subscribeChanges: PubSub.subscribe(changes), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLayer.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-sync-failure-", - }), - ), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [ - withBundledCompatibility(codexProvider), - ]); - - yield* Ref.set(failNextList, true); - yield* PubSub.publish(changes, undefined); - - yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); - yield* PubSub.publish(changes, undefined); - - let providers = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && !providers.some((provider) => provider.instanceId === claudeInstanceId); - attempt += 1 - ) { - yield* wait(); - providers = yield* registry.getProviders; - } - - assert.deepStrictEqual( - providers.map((provider) => provider.instanceId).toSorted(), - [codexInstanceId, claudeInstanceId].toSorted(), - ); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - // This test intentionally avoids `mockCommandSpawnerLayer` so the real - // `probeCodexAppServerProvider` path runs — including the full - // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`. - // We point `binaryPath` at a name that cannot exist on any machine so - // the real `ChildProcessSpawner` deterministically returns ENOENT; the - // probe wraps that as `CodexAppServerSpawnError` and - // `checkCodexProviderStatus` turns it into the user-visible "not - // installed" error snapshot. If the aggregator's `syncLiveSources` - // breaks — the `codex_personal`-never-probes bug we are guarding - // against — that snapshot never lands in `getProviders` and the - // assertions below fail. - it.effect("propagates real Codex probe failures to the aggregator at boot", () => - Effect.gen(function* () { - const missingBinary = `t3code_codex_missing_`; - const serverSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providers: { - // Disable every built-in probe that would otherwise spawn - // on the CI host. `enabled: false` short-circuits each - // driver's probe *before* it touches the spawner, so the - // test environment stays isolated from the dev - // machine's PATH. - codex: { enabled: false }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, - }, - // `providerInstances` keys are branded `ProviderInstanceId`; - // the branded index signature rejects plain string literals - // at the TS level even though the runtime schema happily - // accepts + decodes them. Cast the patch to `unknown` so - // the `Schema.decodeSync` below does the real validation. - providerInstances: { - // Matches the shape the user had in `.t3/dev/settings.json` - // when the bug was reported: a custom enabled Codex instance - // pointing at a binary the server has to actually spawn. - codex_personal: { - driver: "codex", - displayName: "Codex Personal", - enabled: true, - config: { - binaryPath: missingBinary, - homePath: `/tmp/${missingBinary}_home`, - }, - }, - } as unknown as ContractServerSettings["providerInstances"], - }), - ), - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const layerProviderRegistry = ProviderRegistryLayer.layer.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydration.layer), - Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), - Layer.provideMerge( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - ), - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge(layerTestHttpClient), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - Layer.provideMerge( - OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), - ), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - // 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 - // what exercises the same failure mode as a misconfigured - // production `binaryPath`. - ); - const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( - Scope.provide(scope), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - let providers = yield* registry.getProviders; - for ( - let attempts = 0; - attempts < 50 && - providers.find((provider) => provider.instanceId === "codex_personal")?.status !== - "error"; - attempts += 1 - ) { - yield* Effect.yieldNow; - providers = yield* registry.getProviders; - } - const codexPersonal = providers.find( - (provider) => provider.instanceId === "codex_personal", - ); - assert.notStrictEqual( - codexPersonal, - undefined, - `Expected the aggregator to know about codex_personal; instead saw: ${providers - .map((provider) => provider.instanceId) - .join(", ")}`, - ); - assert.strictEqual( - codexPersonal?.status, - "error", - "Real Codex probe against a missing binary should surface as 'error' in the aggregator", - ); - assert.strictEqual(codexPersonal?.installed, false); - assert.include(codexPersonal?.message, missingBinary); - assert.include(codexPersonal?.message, "Settings → Providers → Codex → Binary path"); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - // A binary path change must rebuild Codex and publish its new probe result. - it.effect("re-probes when settings change the codex binaryPath", () => - Effect.gen(function* () { - const firstMissing = `t3code_codex_first_`; - const secondMissing = `t3code_codex_second_`; - const spawnedCommands: Array = []; - const secondProbeStarted = yield* Deferred.make(); - const releaseSecondProbe = yield* Deferred.make(); - const allowLazySettingsStream = yield* Deferred.make(); - const mutableServerSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { enabled: true, binaryPath: firstMissing }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, - }, - }), - ), - ); - const serverSettings = { - ...mutableServerSettings, - streamChanges: Stream.unwrap( - Deferred.await(allowLazySettingsStream).pipe( - Effect.as(mutableServerSettings.streamChanges), - ), - ), - } satisfies ServerSettingsModule.ServerSettingsService["Service"]; - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const layerProviderRegistry = ProviderRegistryLayer.layer.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydration.layer), - Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), - Layer.provideMerge( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - ), - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge(layerTestHttpClient), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - Layer.provideMerge( - OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), - ), - Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => - ChildProcessSpawner.make((command) => { - if (command._tag !== "StandardCommand") return spawner.spawn(command); - spawnedCommands.push(command.command); - const beforeSpawn = - command.command === secondMissing - ? Deferred.succeed(secondProbeStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseSecondProbe)), - ) - : Effect.void; - return beforeSpawn.pipe(Effect.andThen(spawner.spawn(command))); - }), - ), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - ); - const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( - Scope.provide(scope), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const codexSnapshots = registry.streamChanges.pipe( - Stream.map((providers) => - providers.find((provider) => provider.instanceId === "codex"), - ), - Stream.filter((provider): provider is ServerProvider => provider !== undefined), - ); - const firstError = yield* Stream.toPull( - codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")), - ); - const currentCodex = (yield* registry.getProviders).find( - (provider) => provider.instanceId === "codex", - ); - const initialCodex = - currentCodex?.status === "error" ? currentCodex : (yield* firstError)[0]; - assert.strictEqual(initialCodex?.status, "error"); - assert.strictEqual(initialCodex?.installed, false); - assert.deepStrictEqual(spawnedCommands, [firstMissing]); - - const pendingRebuild = yield* Stream.toPull( - codexSnapshots.pipe( - Stream.filter((provider) => provider.status === "warning" && !provider.installed), - ), - ); - yield* serverSettings.updateSettings({ - providers: { - codex: { enabled: true, binaryPath: secondMissing }, - }, - }); - // Start the lazy stream only after publishing. A watcher that did - // not subscribe before forking has already lost this update. - yield* Deferred.succeed(allowLazySettingsStream, undefined); - - // Hold the second probe until the aggregator sees the rebuilt - // instance. Its next error must come from the new executable. - yield* Deferred.await(secondProbeStarted); - yield* pendingRebuild; - const rebuiltError = yield* Stream.toPull( - codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")), - ); - yield* Deferred.succeed(releaseSecondProbe, undefined); - const [reprobedCodex] = yield* rebuiltError; - assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); - assert.strictEqual(reprobedCodex?.status, "error"); - assert.strictEqual(reprobedCodex?.installed, false); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("includes unavailable instance snapshots in getProviders", () => - Effect.gen(function* () { - const serverSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { enabled: false }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, - }, - providerInstances: { - ghost_main: { - driver: "ghostDriver", - displayName: "A fork-only driver we don't ship", - enabled: false, - config: { arbitrary: "payload" }, - }, - } as unknown as ContractServerSettings["providerInstances"], - }), - ), - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const layerProviderRegistry = ProviderRegistryLayer.layer.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydration.layer), - Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), - Layer.provideMerge( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - ), - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge(layerTestHttpClient), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - Layer.provideMerge( - OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), - ), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - ); - const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( - Scope.provide(scope), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const providers = yield* registry.getProviders; - const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); - - assert.notStrictEqual(ghost, undefined); - assert.strictEqual(ghost?.driver, "ghostDriver"); - assert.strictEqual(ghost?.availability, "unavailable"); - assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect( - "keeps Cursor disabled and skips provider probing when settings use their defaults", - () => - Effect.gen(function* () { - const serverSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { - enabled: false, - }, - grok: { - enabled: false, - }, - }, - }), - ), - ); - let cursorSpawned = false; - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const layerProviderRegistry = ProviderRegistryLayer.layer.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydration.layer), - Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), - Layer.provideMerge( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - ), - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge(layerTestHttpClient), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - Layer.provideMerge( - OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), - ), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge( - layerMockCommandSpawner((command, args) => { - if (command === "cursor-agent") { - cursorSpawned = true; - } - const joined = args.join(" "); - if (joined === "--version") { - return { - stdout: `${command} 1.0.0\n`, - stderr: "", - code: 0, - }; - } - if (joined === "auth status") { - return { - stdout: '{"authenticated":true}\n', - stderr: "", - code: 0, - }; - } - throw new Error(`Unexpected args: ${command} ${joined}`); - }), - ), - ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - layerProviderRegistry, - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const providers = yield* registry.getProviders; - const cursorProvider = providers.find( - (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), - ); - - assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ - "antigravity", - "claudeAgent", - "codex", - "cursor", - "grok", - "opencode", - "pi", - ]); - assert.strictEqual(cursorProvider?.enabled, false); - assert.strictEqual(cursorProvider?.status, "disabled"); - assert.strictEqual(cursorProvider?.message, "Cursor is disabled in T3 Code settings."); - assert.strictEqual(cursorSpawned, false); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("skips codex probes entirely when the provider is disabled", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( - Effect.provide(layerFailingSpawner("spawn codex ENOENT")), - ); - assert.strictEqual(status.enabled, false); - assert.strictEqual(status.status, "disabled"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); - }), - ); - }); - - // ── checkClaudeProviderStatus tests ────────────────────────── - - describe("checkClaudeProviderStatus", () => { - it.effect("returns ready when claude is installed and authenticated", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "authenticated"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => - Effect.gen(function* () { - // Bedrock authenticates via external AWS credentials, so the SDK init - // reports only `apiProvider` with no subscription or token. - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ apiProvider: "bedrock" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "bedrock"); - assert.strictEqual(status.auth.label, "Amazon Bedrock"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns a display label for claude subscription types", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ subscriptionType: "maxplan" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "maxplan"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("reads banked resets only for subscription logins", () => - Effect.gen(function* () { - const check = (overrides: Partial) => - checkClaudeProviderStatus( - defaultClaudeSettings, - () => - Effect.succeed({ - email: undefined, - subscriptionType: undefined, - tokenSource: undefined, - apiProvider: undefined, - slashCommands: [], - usage: { rate_limits_available: true, rate_limits: {} }, - ...overrides, - }), - undefined, - undefined, - undefined, - undefined, - () => Effect.succeed({ availableCount: 2 }), - ); - const subscription = yield* check({ subscriptionType: "max" }); - const bedrock = yield* check({ apiProvider: "bedrock" }); - assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 }); - assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("does not duplicate Claude in full subscription labels", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "Claude Max Subscription", - }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "Claude Max Subscription"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("does not duplicate Claude in provider-prefixed subscription names", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "Claude Max", - }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "Claude Max"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns claude auth email from initialization result", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ email: "claude@example.com" }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.email, "claude@example.com"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: - '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { - const claudeConfigDir = "/tmp/t3code-claude-home"; - const recorded = recordingMockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }); - - return Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - { - ...defaultClaudeSettings, - homePath: claudeConfigDir, - }, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "ready"); - // The home is resolved through the host Path before it reaches the env. - assert.deepStrictEqual( - recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), - [(yield* Path.Path).resolve(claudeConfigDir)], - ); - }).pipe(Effect.provide(recorded.layer)); - }); - - it.effect("includes probed claude slash commands in the provider snapshot", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "maxplan", - slashCommands: [ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, - }, - ], - }), - ); - - assert.deepStrictEqual(status.slashCommands.slice(1), [ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, - }, - ]); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("deduplicates probed claude slash commands by name", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "maxplan", - slashCommands: [ - { - name: "ui", - description: "Explore and refine UI", - }, - { - name: "ui", - input: { hint: "component-or-screen" }, - }, - ], - }), - ); - - assert.deepStrictEqual(status.slashCommands, [ - COMPACT_SLASH_COMMAND, - { - name: "ui", - description: "Explore and refine UI", - input: { hint: "component-or-screen" }, - }, - ]); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns an api key label for claude api key auth", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "Claude API Key"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"api-key"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unavailable when claude is missing", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual(status.message, "Claude Agent CLI (`claude`) was not found on PATH."); - }).pipe(Effect.provide(layerFailingSpawner("spawn claude ENOENT"))), - ); - - it.effect("returns error when version check fails with non-zero exit code", () => { - const secretStderr = "Something went wrong: secret-token-value"; - return Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); - assert.ok(!(status.message ?? "").includes(secretStderr)); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") - return { - stdout: "", - stderr: secretStderr, - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ); - }); - - it.effect("returns warning when the Claude initialization result is unavailable", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - noClaudeCapabilities, - ); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Could not verify Claude authentication status from initialization result.", - ); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":false}\n', - stderr: "", - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - }); -}); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts deleted file mode 100644 index 189c5cdf549b36cc49f13a5dc101408b6767f987..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ /dev/null @@ -1,1000 +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 * as ModelManifest from "../ModelManifest.ts"; -import { applyProviderCompatibility } from "../providerCompatibility.ts"; -import * as ServerConfig from "../../config.ts"; -import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; -import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; -import { - hydrateCachedProvider, - isCachedProviderCorrelated, - orderProviderSnapshots, - readProviderStatusCache, - resolveProviderStatusCachePath, - writeProviderStatusCache, -} from "../providerStatusCache.ts"; -import type { ProviderInstance, ProviderWorkspaceSnapshot } 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 MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; - -function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): ServerProvider { - return provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === cwd) - ? { - ...provider, - workspaceSnapshots: provider.workspaceSnapshots.filter((snapshot) => snapshot.cwd !== cwd), - } - : provider; -} - -export function upsertProviderWorkspaceSnapshot( - provider: ServerProvider, - cwd: string, - scopedSnapshot: ProviderWorkspaceSnapshot, -): ServerProvider { - const workspaceSnapshot = { - cwd, - checkedAt: scopedSnapshot.checkedAt, - slashCommands: scopedSnapshot.slashCommandsPending - ? (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd)?.slashCommands ?? - scopedSnapshot.slashCommands) - : scopedSnapshot.slashCommands, - ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), - skills: scopedSnapshot.skills, - } satisfies NonNullable[number]; - return { - ...provider, - workspaceSnapshots: [ - ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd), - workspaceSnapshot, - ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER), - }; -} - -const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { - if (provider.driver === ProviderDriverKind.make("acpRegistry")) { - // ACP Registry discovery probes return the agent's complete inventory, so - // a completed probe (ready and authenticated) replaces the model list — - // otherwise agents that rename or collapse models leave stale entries - // pinned forever through the snapshot cache. Readiness-only and failed - // probe snapshots only know the "default" placeholder and stay partial. - return !( - provider.installed && - provider.status === "ready" && - provider.auth.status === "authenticated" - ); - } - - const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); - const isCodex = provider.driver === ProviderDriverKind.make("codex"); - if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { - return true; - } - - if ( - (isAntigravity || isCodex) && - (!provider.enabled || provider.auth.status === "unauthenticated") - ) { - return false; - } - - // Successful discovery replaces these inventories so cached retired models disappear. - // Antigravity's local health check does not authenticate or discover models. - const isPendingAntigravityAuthentication = - isAntigravity && provider.status === "warning" && provider.auth.status === "unknown"; - const isPendingInitialProbe = - provider.enabled && !provider.installed && provider.status === "warning"; - const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; - return ( - isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail - ); -}; - -const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => - provider.driver === ProviderDriverKind.make("opencode") && - shouldRetainMissingProviderModels(provider); - -const mergeProviderModels = ( - provider: ServerProvider, - previousModels: ReadonlyArray, - nextModels: ReadonlyArray, -): ReadonlyArray => { - const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); - // Custom rows are derived from settings and every snapshot carries the full - // current list, so a custom model missing from `nextModels` was removed by - // the user and must not be resurrected from the previous snapshot. - const retainablePreviousModels = previousModels.filter((model) => !model.isCustom); - - if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { - return retainablePreviousModels; - } - - 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 shouldRetainMissingModels - ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))] - : mergedModels; -}; - -/** - * Antigravity's health check only initializes the agent, so after a server - * restart it reports the account as unchecked. The saved Google login still - * works, and the previous snapshot proves it. Carry that account state until - * a session, refresh, or sign-out reports something new. A confirmed missing - * installation, sign-out, disabled instance, or a changed sign-in method is - * never overridden. - */ -const carrySavedAntigravityAccount = ( - previousProvider: ServerProvider, - nextProvider: ServerProvider, -): Pick | undefined => { - const antigravity = ProviderDriverKind.make("antigravity"); - if ( - nextProvider.driver !== antigravity || - previousProvider.driver !== antigravity || - !nextProvider.enabled || - nextProvider.auth.status !== "unknown" || - previousProvider.auth.status !== "authenticated" || - (nextProvider.auth.type !== undefined && - nextProvider.auth.type !== previousProvider.auth.type) || - (!nextProvider.installed && nextProvider.status !== "warning") - ) { - return undefined; - } - // The pending boot probe (`installed: false`, warning) and a failed probe - // keep their own status; only a passed health check reads as ready. - const status = - nextProvider.installed && nextProvider.status === "warning" ? "ready" : nextProvider.status; - return { auth: previousProvider.auth, status }; -}; - -export const mergeProviderSnapshot = ( - previousProvider: ServerProvider | undefined, - nextProvider: ServerProvider, -): ServerProvider => { - if (!previousProvider) { - return nextProvider; - } - const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); - // "Google account access is not checked yet" describes the probe, not the - // account; it must not outlive the state it explained. - const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; - return { - ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider), - ...savedAccount, - models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), - ...(nextProvider.workspaceSnapshots !== undefined - ? { workspaceSnapshots: nextProvider.workspaceSnapshots } - : previousProvider.workspaceSnapshots !== undefined - ? { workspaceSnapshots: previousProvider.workspaceSnapshots } - : {}), - ...(shouldRetainMissingOpenCodeMetadata(nextProvider) - ? { - slashCommands: - nextProvider.slashCommands.length === 0 - ? previousProvider.slashCommands - : nextProvider.slashCommands, - skills: nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, - } - : {}), - }; -}; - -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)); - -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 layer = Layer.effect( - ProviderRegistry.ProviderRegistry, - Effect.gen(function* () { - const instanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; - const manifestService = yield* ModelManifest.ModelManifest; - const serviceScope = yield* Effect.scope; - const config = yield* ServerConfig.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 initialManifest = yield* manifestService.current; - const classifyCompatibility = ( - provider: ServerProvider, - manifest: ModelManifest.ModelManifestData, - ) => - applyProviderCompatibility( - provider, - manifest.compatibility, - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - ); - const providersRef = yield* Ref.make>( - cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), - ); - const workspaceRefreshesRef = yield* Ref.make< - ReadonlyMap> - >(new Map()); - 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)); - const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; - yield* writeProviderStatusCache({ filePath, provider: machineProvider }).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 manifest = yield* manifestService.current; - 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()].map((provider) => - classifyCompatibility(provider, manifest), - ), - ); - 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 compatibilityRefreshRunning = yield* Ref.make(false); - const syncProvider = Effect.fn("syncProvider")(function* ( - provider: ServerProvider, - options?: { - readonly publish?: boolean; - }, - ) { - const providers = yield* upsertProviders([provider], options); - // Reclassify the current read model after fetching. Never republish the - // probe captured before the fetch: a newer health result may have landed. - if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { - yield* manifestService.refresh.pipe( - Effect.andThen(upsertProviders([], { persist: false })), - Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)), - Effect.forkIn(serviceScope), - ); - } - return providers; - }); - - 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, - options?: { readonly fresh?: boolean }, - ) { - // Read the instance registry, not `liveSubsRef`: the latter trails - // reconciliation, and an update must never run a retired instance's - // command against a freshly configured executable. - const instance = yield* instanceRegistry.getInstance(instanceId); - if (!instance || instance.driverKind !== provider) { - return makeManualProviderMaintenanceCapabilities(provider); - } - return yield* instance.snapshot.resolveMaintenance(options); - }); - - /** - * 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); - } - - const rebuiltInstanceIds = new Set( - newlyAdded - .map(([instanceId]) => instanceId) - .filter((instanceId) => previousSubs.has(instanceId)), - ); - if (rebuiltInstanceIds.size > 0) { - const [previousProviders, providers] = yield* Ref.modify( - providersRef, - (previousProviders) => { - const providers = previousProviders.map((provider) => { - if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; - const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; - return machineSnapshot; - }); - return [[previousProviders, providers] as const, providers]; - }, - ); - if (haveProvidersChanged(previousProviders, providers)) { - yield* PubSub.publish(changesPubSub, providers); - } - } - - // 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); - }); - - const updateProviders = ( - update: (providers: ReadonlyArray) => ReadonlyArray, - ) => - Ref.modify(providersRef, (currentProviders) => { - const nextProviders = update(currentProviders); - return [[currentProviders, nextProviders] as const, nextProviders]; - }).pipe( - Effect.tap(([previousProviders, nextProviders]) => - haveProvidersChanged(previousProviders, nextProviders) - ? PubSub.publish(changesPubSub, nextProviders) - : Effect.void, - ), - Effect.map(([, nextProviders]) => nextProviders), - ); - - const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { - readonly instanceId: ProviderInstanceId; - readonly cwd: string; - readonly fresh?: boolean; - }) { - // Fresh scans drop other instances' snapshots for this cwd first, so a - // composer on one of them scans again on next use, even when this - // instance is gone or cannot be scanned. - if (input.fresh) { - yield* updateProviders((providers) => - providers.map((candidate) => - candidate.instanceId === input.instanceId - ? candidate - : dropProviderWorkspaceSnapshot(candidate, input.cwd), - ), - ); - } - const providers = yield* Ref.get(providersRef); - const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); - const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => - candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); - const scannedFrom = workspaceSnapshotOf(provider); - if ( - !provider || - !provider.enabled || - (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending) - ) { - return providers; - } - const instance = yield* instanceRegistry.getInstance(input.instanceId); - if (!instance?.snapshotForCwd) return providers; - const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { - const current = refreshes.get(instance); - if (current?.has(input.cwd)) return [false, refreshes] as const; - const next = new Map(refreshes); - next.set(instance, new Set(current).add(input.cwd)); - return [true, next] as const; - }); - // A fresh scan never joins a running one, which may predate the change. - if (!claimed && !input.fresh) return yield* Ref.get(providersRef); - // Fresh scans also re-read the machine snapshot after invalidating caches. - const refreshMachineSnapshot = input.fresh - ? (instance.invalidateCaches ?? Effect.void).pipe( - Effect.andThen(refreshInstance(input.instanceId)), - ) - : Effect.void; - return yield* refreshMachineSnapshot.pipe( - Effect.andThen(instance.snapshotForCwd(input.cwd)), - Effect.flatMap((scopedSnapshot) => - scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined - ? Ref.get(providersRef) - : instanceRegistry.getInstance(input.instanceId).pipe( - Effect.flatMap((currentInstance) => { - if (currentInstance !== instance) return Ref.get(providersRef); - // Write only if the cwd's snapshot did not change during the - // scan. A session event or another scan that landed first is newer. - return updateProviders((currentProviders) => - currentProviders.map((candidate) => - candidate.instanceId === input.instanceId && - Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) - ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) - : candidate, - ), - ); - }), - ), - ), - Effect.ensuring( - claimed - ? Ref.update(workspaceRefreshesRef, (refreshes) => { - const next = new Map(refreshes); - const current = new Set(next.get(instance)); - current.delete(input.cwd); - if (current.size) next.set(instance, current); - else next.delete(instance); - return next; - }) - : Effect.void, - ), - ); - }); - - return { - getProviders: Ref.get(providersRef), - refresh: (provider?: ProviderDriverKind) => - refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), - refreshInstance: (instanceId: ProviderInstanceId) => - refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), - refreshWorkspaceSnapshot: (input) => - refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), - getProviderMaintenanceCapabilitiesForInstance, - setProviderMaintenanceActionState, - get streamChanges() { - return Stream.fromPubSub(changesPubSub); - }, - } satisfies ProviderRegistry.ProviderRegistryShape; - }), -); diff --git a/apps/server/src/provider/Layers/ProviderUsageLimitsIngestion.ts b/apps/server/src/provider/Layers/ProviderUsageLimitsIngestion.ts deleted file mode 100644 index aebd4d12159efeaba33c947c5fa19ecb2ff187ef..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/ProviderUsageLimitsIngestion.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * V2 drivers fold live account usage events directly into their managed - * snapshots. Retained as an empty layer for the shared server composition. - */ -import * as Layer from "effect/Layer"; - -export const layer = Layer.empty; diff --git a/apps/server/src/provider/Layers/claudeResetCredits.test.ts b/apps/server/src/provider/Layers/claudeResetCredits.test.ts deleted file mode 100644 index 0dcb409ffa3c12be05be00d7326d992595eba809..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/claudeResetCredits.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it as effectIt } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Fiber from "effect/Fiber"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse, UrlParams } from "effect/http"; -import { describe, expect, it } from "vite-plus/test"; - -import * as ClaudeResetCredits from "./claudeResetCredits.ts"; - -const NOW = Date.parse("2026-09-22T12:00:00.000Z"); -const grant = (overrides: Record) => ({ - id: "grant_a", - resets_left: 1, - usable_now: true, - ...overrides, -}); - -const writeLogin = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - yield* fs.writeFileString( - path.join(directory, ".credentials.json"), - '{"claudeAiOauth":{"accessToken":"oauth-token"}}', - ); - const accountConfigPath = path.join(directory, ".claude.json"); - yield* fs.writeFileString(accountConfigPath, '{"oauthAccount":{"organizationUuid":"org-1"}}'); - return { configDir: directory, accountConfigPath }; -}); - -const respond = (status: number, body: unknown) => - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))), - ); -const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); - -describe("claudeResetCreditsToContract", () => { - it("counts live grants and pins the next usable one", () => { - expect( - ClaudeResetCredits.claudeResetCreditsToContract( - { - eligible: true, - next_grant_id: "grant_a", - grants: [ - grant({ resets_left: 2, ends_at: "2026-10-01T00:00:00Z" }), - grant({ id: "paused", paused: true }), - grant({ id: "expired", ends_at: "2026-09-01T00:00:00Z" }), - grant({ id: "garbled", ends_at: "not a date" }), - grant({ id: "date_only", ends_at: "2026-10-01" }), - grant({ id: "impossible", ends_at: "2027-02-30T00:00:00Z" }), - grant({ id: "empty", ends_at: "" }), - grant({ id: "Not Valid" }), - grant({ id: "grant_b", resets_left: 3, usable_now: false }), - ], - }, - NOW, - ), - ).toEqual({ - availableCount: 2, - nextCreditId: "grant_a", - nextExpiresAt: "2026-10-01T00:00:00.000Z", - }); - }); - - it("offers nothing to redeem without a usable next grant or an eligible account", () => { - expect( - ClaudeResetCredits.claudeResetCreditsToContract( - { eligible: true, next_grant_id: "grant_a", grants: [grant({ usable_now: false })] }, - NOW, - ), - ).toEqual({ availableCount: 0 }); - expect( - ClaudeResetCredits.claudeResetCreditsToContract({ eligible: true, grants: [grant({})] }, NOW), - ).toEqual({ - availableCount: 0, - }); - expect( - ClaudeResetCredits.claudeResetCreditsToContract( - { eligible: false, grants: [grant({})] }, - NOW, - ), - ).toBeUndefined(); - expect(ClaudeResetCredits.claudeResetCreditsToContract(undefined, NOW)).toBeUndefined(); - }); -}); - -effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { - it.effect("reads the grants with the CLI's request", () => - Effect.gen(function* () { - const { configDir } = yield* writeLogin; - const client = HttpClient.make((request) => { - expect(request.method).toBe("GET"); - expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage"); - expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&skip_spend=1"); - expect(request.headers.authorization).toBe("Bearer oauth-token"); - expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20"); - expect(request.headers["user-agent"]).toBe("claude-cli/2.1.0 (external, cli)"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ - cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, - }), - ), - ); - }); - const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HttpClient.HttpClient, client), - ); - expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); - }), - ); - - it.effect("reads nothing from keychain logins or failed requests", () => - Effect.gen(function* () { - const { configDir } = yield* writeLogin; - const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( - Effect.provideService(HostProcessPlatform, "darwin"), - Effect.provideService(HttpClient.HttpClient, refuseRequests), - ); - const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HttpClient.HttpClient, respond(429, {})), - ); - expect([darwin, limited]).toEqual([undefined, undefined]); - }), - ); -}); - -const ClaimBody = Schema.fromJsonString( - Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }), -); -const decodeClaimBody = Schema.decodeEffect(ClaimBody); - -const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requestId: "r-1" }) => - Effect.gen(function* () { - const login = yield* writeLogin; - return yield* ClaudeResetCredits.consumeClaudeResetCredit({ - ...login, - version: "2.1.0", - ...ids, - }).pipe( - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HttpClient.HttpClient, client), - Effect.result, - ); - }); - -effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { - it.effect("claims the grant for the organization", () => - Effect.gen(function* () { - const client = HttpClient.make((request) => - Effect.gen(function* () { - expect(request.method).toBe("POST"); - expect(request.url).toBe( - "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits", - ); - expect(request.headers.authorization).toBe("Bearer oauth-token"); - const body = - request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; - expect(yield* decodeClaimBody(body)).toEqual({ - program: "cedar_ember", - grant_id: "grant_a", - request_id: "r-1", - }); - return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); - }).pipe(Effect.orDie), - ); - expect(yield* consume(client)).toMatchObject({ _tag: "Success", success: "reset" }); - }), - ); - - it.effect("maps each answer to an outcome or a failure", () => - Effect.gen(function* () { - for (const [result, outcome] of [ - ["not_limited", "nothingToReset"], - ["already_used", "alreadyRedeemed"], - ["ineligible", "noCredit"], - ] as const) { - expect(yield* consume(respond(200, { result }))).toMatchObject({ success: outcome }); - } - for (const client of [ - respond(200, { result: "cooldown" }), - respond(429, {}), - respond(401, {}), - ]) { - const result = yield* consume(client); - expect(result).toMatchObject({ _tag: "Failure" }); - // Claude answered, so a retry must be a new claim. - if (result._tag === "Failure") { - expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(result.failure)).toBe(true); - } - } - // No answer, or Claude could not confirm the claim: a retry is the same claim. - for (const client of [respond(500, {}), respond(200, { result: "unavailable" })]) { - const unanswered = yield* consume(client); - expect(unanswered).toMatchObject({ _tag: "Failure" }); - if (unanswered._tag === "Failure") { - expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(unanswered.failure)).toBe( - false, - ); - } - } - }), - ); - - it.effect("times out a stalled claim body", () => - Effect.gen(function* () { - const login = yield* writeLogin; - const readingBody = yield* Deferred.make(); - const client = HttpClient.make((request) => { - const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); - Object.defineProperty(response, "json", { - value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)), - }); - return Effect.succeed(response); - }); - const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({ - ...login, - version: "2.1.0", - grantId: "grant_a", - requestId: "r-1", - }).pipe( - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HttpClient.HttpClient, client), - Effect.result, - Effect.forkChild, - ); - yield* Deferred.await(readingBody); - yield* TestClock.adjust("26 seconds"); - expect(yield* Fiber.join(claim)).toMatchObject({ - _tag: "Failure", - failure: { - _tag: "ClaudeResetCreditError", - reason: "requestFailed", - cause: { _tag: "TimeoutError" }, - }, - }); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("refuses malformed ids without sending anything", () => - Effect.gen(function* () { - for (const ids of [ - { grantId: "Bad Grant", requestId: "r-1" }, - { grantId: "grant_a", requestId: "has space" }, - ]) { - expect(yield* consume(refuseRequests, ids)).toMatchObject({ _tag: "Failure" }); - } - }), - ); -}); diff --git a/apps/server/src/provider/Layers/claudeResetCredits.ts b/apps/server/src/provider/Layers/claudeResetCredits.ts deleted file mode 100644 index ff2d5778ec9fab9ee9c4af3b3dc9f0df5a85451d..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/claudeResetCredits.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Claude banked resets (the CLI's `cedar_ember` program). The CLI reads the - * grants from the OAuth usage endpoint and claims one against the - * organization; this module does the same with the credentials the CLI keeps - * in its config directory. macOS keeps them in the keychain, so there the - * feature is not offered. - * - * @module provider/Layers/claudeResetCredits - */ -import * as NodeOS from "node:os"; -import type { - ProviderConsumeResetCreditOutcome, - ServerProviderResetCredits, -} from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; - -const API_BASE = "https://api.anthropic.com"; -const PROGRAM = "cedar_ember"; -const GRANT_ID = /^[a-z0-9_-]{1,40}$/; -const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/; -const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; - -const Credentials = Schema.Struct({ - claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })), -}); -const Config = Schema.Struct({ - oauthAccount: Schema.optional( - Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }), - ), -}); -const Grant = Schema.Struct({ - id: Schema.String.check(Schema.isPattern(GRANT_ID)), - resets_left: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - ends_at: Schema.optional(Schema.NullOr(Schema.String)), - paused: Schema.optional(Schema.Boolean), - usable_now: Schema.optional(Schema.Boolean), -}); -const decodeGrant = Schema.decodeUnknownOption(Grant); -const CedarEmber = Schema.Struct({ - eligible: Schema.Boolean, - grants: Schema.optional(Schema.Array(Schema.Unknown)), - next_grant_id: Schema.optional(Schema.NullOr(Schema.String)), -}); -const UsageResponse = Schema.Struct({ - cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), -}); -const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); -const ClaimResponse = Schema.Struct({ - result: Schema.Literals([ - "reset", - "already_used", - "not_limited", - "cooldown", - "ineligible", - "unavailable", - ]), -}); - -const RESET_CREDIT_FAILURES = { - malformedCredit: "Claude returned a malformed reset credit.", - loginUnreadable: "Claude could not read its login.", - accountUnreadable: "Claude could not read its account.", - signedOut: "Sign in to Claude again to redeem resets.", - rateLimited: "Claude is rate limiting resets. Try again soon.", - coolingDown: "Claude resets are cooling down. Try again later.", - unconfirmed: - "Claude could not confirm the reset. If you are still limited in a moment, try again.", - requestFailed: "Claude could not redeem the reset.", -} as const; - -class ClaudeResetCreditError extends Schema.TaggedError()( - "ClaudeResetCreditError", - { - reason: Schema.Literals( - Object.keys(RESET_CREDIT_FAILURES) as Array, - ), - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - return RESET_CREDIT_FAILURES[this.reason]; - } -} - -const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); - -/** - * Every reset failure except `requestFailed` and `unconfirmed` is final: - * Claude answered, or nothing was sent. An unanswered or unconfirmed claim - * retries with the same request id. - */ -export const isSettledClaudeResetCreditFailure = (error: unknown) => - isClaudeResetCreditError(error) && - error.reason !== "requestFailed" && - error.reason !== "unconfirmed"; - -/** Rejects unparseable and calendar-invalid timestamps such as February 30. */ -const isFutureTimestamp = (value: string, nowMs: number) => { - if (!COMPLETE_TIMESTAMP.test(value)) return false; - const [year, month, day] = value.slice(0, 10).split("-").map(Number); - return ( - Date.parse(value) > nowMs && Date.UTC(year!, month! - 1, day!) <= Date.UTC(year!, month!, 0) - ); -}; - -/** Grants that are paused or past `ends_at` cannot be claimed and do not count. */ -export function claudeResetCreditsToContract( - block: unknown, - nowMs: number, -): ServerProviderResetCredits | undefined { - const parsed = decodeCedarEmber(block); - if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; - const live = (parsed.value.grants ?? []) - .flatMap((raw) => Option.toArray(decodeGrant(raw))) - .filter( - (grant) => - !grant.paused && - grant.usable_now && - (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), - ); - const next = live.find((grant) => grant.id === parsed.value.next_grant_id); - const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); - return { - availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, - ...(Option.isSome(nextExpiresAt) - ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } - : {}), - ...(next ? { nextCreditId: next.id } : {}), - }; -} - -const readJson = (schema: S, file: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.readFileString(file).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), - ); - }); - -const readAccessToken = (configDir: string) => - Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "darwin") return undefined; - const path = yield* Path.Path; - const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); - return credentials.claudeAiOauth?.accessToken?.trim() || undefined; - }); - -const withClaudeHeaders = (token: string, version: string) => - HttpClientRequest.setHeaders({ - authorization: `Bearer ${token}`, - "anthropic-beta": "oauth-2025-04-20", - "user-agent": `claude-cli/${version} (external, cli)`, - }); - -/** - * Reads the banked resets for the login in `configDir`. Any failure reads as - * "no resets" so the usage bars never break on this optional extra. - */ -export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( - function* (configDir: string, version: string) { - const token = yield* readAccessToken(configDir); - if (!token) return undefined; - const client = yield* HttpClient.HttpClient; - const response = yield* client.execute( - HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { - urlParams: { cedar_ember: "1", skip_spend: "1" }, - }).pipe(withClaudeHeaders(token, version)), - ); - const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)( - yield* HttpClientResponse.filterStatusOk(response), - ); - return claudeResetCreditsToContract( - body.cedar_ember, - DateTime.toEpochMillis(yield* DateTime.now), - ); - }, - Effect.timeout("10 seconds"), - Effect.orElseSucceed(() => undefined), -); - -/** The CLI keeps the account record beside its settings, or in the home directory by default. */ -export const claudeAccountConfigPath = (configDir: string | undefined) => - Effect.map(Path.Path, (path) => - configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"), - ); - -const CLAIM_OUTCOMES = { - reset: "reset", - not_limited: "nothingToReset", - already_used: "alreadyRedeemed", - ineligible: "noCredit", -} as const satisfies Record; - -/** - * Claims `grantId`. `requestId` is the idempotency key: a retry with the same - * id is the same claim. Ids are checked before anything is sent. - */ -export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { - readonly configDir: string; - readonly accountConfigPath: string; - readonly version: string; - readonly grantId: string; - readonly requestId: string; -}) { - if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { - return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); - } - const token = yield* readAccessToken(input.configDir).pipe( - Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })), - ); - const config = yield* readJson(Config, input.accountConfigPath).pipe( - Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })), - ); - const organization = config.oauthAccount?.organizationUuid?.trim(); - if (!token || !organization) { - return yield* new ClaudeResetCreditError({ reason: "signedOut" }); - } - const client = yield* HttpClient.HttpClient; - const response = yield* client - .execute( - HttpClientRequest.post( - new URL( - `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, - API_BASE, - ), - ).pipe( - withClaudeHeaders(token, input.version), - HttpClientRequest.bodyJsonUnsafe({ - program: PROGRAM, - grant_id: input.grantId, - request_id: input.requestId, - }), - ), - ) - .pipe( - Effect.timeout("25 seconds"), - Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), - ); - if (response.status === 429) { - return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); - } - if (response.status === 401 || response.status === 403) { - return yield* new ClaudeResetCreditError({ reason: "signedOut" }); - } - const body = yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)), - Effect.timeout("25 seconds"), - Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), - ); - if (body.result === "cooldown") { - return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); - } - // Claude could not say whether the claim landed, so, like the CLI, keep the - // request id and let the retry ask about the same claim. - if (body.result === "unavailable") { - return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); - } - return CLAIM_OUTCOMES[body.result]; -}); diff --git a/apps/server/src/provider/Layers/claudeUsageLimits.test.ts b/apps/server/src/provider/Layers/claudeUsageLimits.test.ts deleted file mode 100644 index a3d326a7dcb2104c0ac433852625f113f2798400..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/claudeUsageLimits.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts"; - -const checkedAt = "2026-07-18T10:00:00.000Z"; -const noNames = { overageIncluded: undefined } as const; - -describe("claudeUsageResponseToLimits", () => { - it("maps the session, weekly, and model-scoped weekly windows", () => { - expect( - claudeUsageResponseToLimits({ - checkedAt, - response: { - rate_limits_available: true, - rate_limits: { - five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" }, - seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" }, - seven_day_opus: { utilization: 3, resets_at: null }, - // Newer CLIs add this on top of the typed keys; the pinned SDK - // typings do not know it yet. - ...({ - model_scoped: [ - { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" }, - { display_name: "Ghost", utilization: null, resets_at: null }, - ], - } as object), - extra_usage: { - is_enabled: false, - monthly_limit: null, - used_credits: null, - utilization: null, - }, - }, - }, - }), - ).toEqual({ - names: { overageIncluded: "Fable" }, - limits: { - checkedAt, - windows: [ - { - id: "five_hour", - kind: "session", - label: "Session", - usedPercent: 54, - windowDurationMins: 300, - resetsAt: "2026-07-18T14:39:00.000Z", - }, - { - id: "seven_day", - kind: "weekly", - label: "Weekly", - usedPercent: 18.4, - windowDurationMins: 10080, - resetsAt: "2026-07-24T08:59:00.000Z", - }, - { - id: "seven_day_fable", - kind: "weekly", - label: "Weekly · Fable", - usedPercent: 73, - windowDurationMins: 10080, - resetsAt: "2026-07-24T08:59:00.000Z", - }, - ], - }, - }); - }); - - it("names the overage-included bucket only from a scoped entry that drew a row", () => { - expect( - claudeUsageResponseToLimits({ - checkedAt, - response: { - rate_limits_available: true, - rate_limits: { - ...({ - model_scoped: [ - { display_name: "Ghost", utilization: null, resets_at: null }, - { display_name: "Fable", utilization: 5, resets_at: null }, - ], - } as object), - }, - }, - }).names, - ).toEqual({ overageIncluded: "Fable" }); - }); - - it("reports API key and Bedrock accounts as unsupported", () => { - expect( - claudeUsageResponseToLimits({ - checkedAt, - response: { rate_limits_available: false, rate_limits: null }, - }).limits, - ).toEqual({ checkedAt, windows: [], unavailable: { reason: "unsupported" } }); - }); - - it("skips a window the endpoint reports without a utilization", () => { - expect( - claudeUsageResponseToLimits({ - checkedAt, - response: { - rate_limits_available: true, - rate_limits: { - five_hour: { utilization: null, resets_at: null }, - seven_day: { utilization: 250, resets_at: null }, - }, - }, - }).limits.windows, - ).toEqual([ - { - id: "seven_day", - kind: "weekly", - label: "Weekly", - usedPercent: 100, - windowDurationMins: 10080, - }, - ]); - }); -}); - -describe("claudeRateLimitEventToUpdate", () => { - it("scales the 0–1 utilization and epoch-second reset onto the probe's window id", () => { - expect( - claudeRateLimitEventToUpdate( - { - status: "allowed_warning", - rateLimitType: "seven_day", - utilization: 0.85, - resetsAt: 1_784_000_000, - }, - noNames, - ), - ).toEqual({ - windows: [ - { - id: "seven_day", - kind: "weekly", - label: "Weekly", - usedPercent: 85, - windowDurationMins: 10080, - resetsAt: "2026-07-14T03:33:20.000Z", - }, - ], - }); - }); - - it("lands the streamed overage-included bucket on the row the probe named", () => { - const event = { - status: "allowed", - rateLimitType: "seven_day_overage_included" as never, - utilization: 0.4, - } as const; - // No probe has named the bucket yet: guessing would open a stray row. - expect(claudeRateLimitEventToUpdate(event, noNames)).toBeUndefined(); - expect(claudeRateLimitEventToUpdate(event, { overageIncluded: "Fable" })).toEqual({ - windows: [ - { - id: "seven_day_fable", - kind: "weekly", - label: "Weekly · Fable", - usedPercent: 40, - windowDurationMins: 10080, - }, - ], - }); - }); - - it("ignores windows the page does not render and events without a utilization", () => { - expect( - claudeRateLimitEventToUpdate( - { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 }, - noNames, - ), - ).toBeUndefined(); - expect( - claudeRateLimitEventToUpdate({ status: "rejected", rateLimitType: "five_hour" }, noNames), - ).toBeUndefined(); - }); -}); diff --git a/apps/server/src/provider/Layers/claudeUsageLimits.ts b/apps/server/src/provider/Layers/claudeUsageLimits.ts deleted file mode 100644 index b67645102e5aaa16f42e90bcffaa44f80f2ab800..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/claudeUsageLimits.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Claude Code subscription usage. Both sources produce windows with the same - * ids so a turn-driven `rate_limit_event` lands on the row the SDK's - * `get_usage` read established: - * - * - `get_usage` (on demand, during the capabilities probe) reports every - * window at once as 0–100 percentages with ISO reset times. - * - `rate_limit_event` (streamed during a turn) names one window at a time - * with a 0–1 utilization fraction and an epoch-seconds reset. - * - * @module provider/Layers/claudeUsageLimits - */ -import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk"; -import type { - ProviderUsageLimitsUpdate, - ServerProviderUsageLimits, - ServerProviderUsageWindow, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; - -import { - clampPercent, - makeUnavailableUsageLimits, - makeUsageLimits, -} from "../providerUsageLimits.ts"; - -const SESSION_MINS = 5 * 60; -const WEEK_MINS = 7 * 24 * 60; - -/** - * The account-wide windows, keyed by the SDK's `rateLimitType`. Model-scoped - * weeklies are additive on top of these: the CLI reports them under - * `rate_limits.model_scoped[]` on `get_usage` and streams the overage-included - * model bucket (Fable today) as `seven_day_overage_included`. - */ -const WINDOWS: Readonly< - Record> -> = { - five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS }, - seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS }, -}; - -/** - * The streamed event names the overage-included bucket by type - * (`seven_day_overage_included`), while `get_usage` names it by the model's - * `display_name`. Which model that is changes over time, so the probe records - * the name it saw and the event mapper reuses it; the mid-turn update then - * lands on the row the probe drew instead of opening a second one. - */ -const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; - -export interface ClaudeScopedLimitNames { - readonly overageIncluded: string | undefined; -} - -export const makeClaudeScopedLimitNames = Ref.make({ - overageIncluded: undefined, -}); - -function scopedWindowId(displayName: string): string { - return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`; -} - -function scopedWindow( - displayName: string, - usedPercent: number, - resetsAt: string | undefined, -): ServerProviderUsageWindow { - return { - id: scopedWindowId(displayName), - kind: "weekly", - label: `Weekly · ${displayName}`, - windowDurationMins: WEEK_MINS, - usedPercent: clampPercent(usedPercent), - ...(resetsAt ? { resetsAt } : {}), - }; -} - -/** - * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is - * read structurally until the `.d.ts` catches up. - */ -interface ModelScopedWindow { - readonly display_name: string; - readonly utilization: number | null; - readonly resets_at: string | null; -} - -function readModelScoped(rateLimits: object): ReadonlyArray { - const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped; - if (!Array.isArray(raw)) return []; - return raw.filter( - (entry): entry is ModelScopedWindow => - typeof entry === "object" && - entry !== null && - typeof (entry as ModelScopedWindow).display_name === "string", - ); -} - -function isoFromEpochSeconds(value: number | undefined): string | undefined { - if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined; - const dt = DateTime.make(value * 1000); - return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; -} - -function isoFromString(value: string | null | undefined): string | undefined { - if (!value) return undefined; - const dt = DateTime.make(value); - return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; -} - -function makeWindow( - id: keyof typeof WINDOWS & string, - usedPercent: number, - resetsAt: string | undefined, -): ServerProviderUsageWindow { - const window = WINDOWS[id]!; - return { - id, - ...window, - usedPercent: clampPercent(usedPercent), - ...(resetsAt ? { resetsAt } : {}), - }; -} - -/** - * Utilization is a 0–1 fraction on the streamed event. An overage-included - * event before any probe has named the bucket is dropped: guessing a name - * would draw a row the next probe cannot reconcile. - */ -export function claudeRateLimitEventToUpdate( - info: SDKRateLimitInfo, - names: ClaudeScopedLimitNames, -): ProviderUsageLimitsUpdate | undefined { - const type: string | undefined = info.rateLimitType; - if (!type || typeof info.utilization !== "number") { - return undefined; - } - const usedPercent = info.utilization * 100; - const resetsAt = isoFromEpochSeconds(info.resetsAt); - if (type in WINDOWS) { - return { windows: [makeWindow(type, usedPercent, resetsAt)] }; - } - if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { - return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] }; - } - return undefined; -} - -/** - * Percentages on the `get_usage` response are already 0–100. Also yields the - * scoped-bucket names the response carried, for the event mapper to reuse. - */ -export function claudeUsageResponseToLimits(input: { - readonly response: Pick; - readonly checkedAt: string; -}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } { - const { response, checkedAt } = input; - if (!response.rate_limits_available || !response.rate_limits) { - return { - limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), - names: { overageIncluded: undefined }, - }; - } - const windows: ServerProviderUsageWindow[] = []; - for (const id of Object.keys(WINDOWS)) { - const window = response.rate_limits[id as "five_hour" | "seven_day"]; - if (!window || typeof window.utilization !== "number") continue; - windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at))); - } - // The CLI filters `model_scoped` to the overage-included allowlist, which - // today holds one model; the first entry is the one the event refers to. - let overageIncluded: string | undefined; - for (const entry of readModelScoped(response.rate_limits)) { - if (typeof entry.utilization !== "number") continue; - windows.push( - scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)), - ); - // Only a bucket that drew a row may receive events; naming one that was - // skipped would let a mid-turn event open a row the probe never showed. - overageIncluded ??= entry.display_name; - } - return { - limits: makeUsageLimits({ checkedAt, windows }), - names: { overageIncluded }, - }; -} - -/** Probe-side helper: map the response and remember the scoped names for events. */ -export const recordClaudeUsageResponse = ( - namesRef: Ref.Ref, - input: Parameters[0], -): Effect.Effect => { - const { limits, names } = claudeUsageResponseToLimits(input); - return Ref.set(namesRef, names).pipe(Effect.as(limits)); -}; diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.test.ts b/apps/server/src/provider/Layers/codexLaunchArgs.test.ts deleted file mode 100644 index 115ac28eaf94586f4580ca6eece0789dbe6772d9..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/codexLaunchArgs.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; - -import { describe, it } from "vite-plus/test"; - -import { - codexAppServerArgs, - codexExecLaunchArgs, - resolveCodexLaunchArgs, -} from "./codexLaunchArgs.ts"; - -describe("resolveCodexLaunchArgs", () => { - it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => { - NodeAssert.equal( - resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }), - "--enable foo", - ); - }); - - it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => { - NodeAssert.equal( - resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }), - "--strict-config", - ); - }); - - it("ignores whitespace-only environment values", () => { - NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), ""); - }); -}); - -describe("codexAppServerArgs", () => { - it("returns the app-server command for empty launch args", () => { - NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]); - }); - - it("appends parsed launch args after app-server", () => { - NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [ - "app-server", - "--strict-config", - "--enable", - "foo", - ]); - }); -}); - -describe("codexExecLaunchArgs", () => { - it("keeps shared codex flags and omits app-server-only flags", () => { - NodeAssert.deepStrictEqual( - codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'), - ["--strict-config", "--enable", "foo", "--config", "model=gpt 5"], - ); - }); - - it("does not pair value-taking flags with adjacent flags", () => { - NodeAssert.deepStrictEqual(codexExecLaunchArgs("--config --strict-config --enable --disable"), [ - "--strict-config", - ]); - }); -}); diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.ts b/apps/server/src/provider/Layers/codexLaunchArgs.ts deleted file mode 100644 index 33def5ce9a61a90e851160fe2ad358140c60cba1..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/codexLaunchArgs.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; - -const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; - -export const resolveCodexLaunchArgs = ( - launchArgs?: string, - environment: NodeJS.ProcessEnv = process.env, -) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; - -const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => tokenizeCliArgs(launchArgs); - -export const codexAppServerArgs = (launchArgs?: string) => [ - "app-server", - ...codexLaunchArgv(launchArgs), -]; - -export const codexExecLaunchArgs = (launchArgs?: string) => { - const args = codexLaunchArgv(launchArgs); - const execArgs: Array = []; - - for (let index = 0; index < args.length; index++) { - const arg = args[index]; - if (arg === undefined) continue; - - if (arg === "--strict-config" || arg.startsWith("--config=") || arg.startsWith("-c=")) { - execArgs.push(arg); - } else if (arg === "--config" || arg === "-c" || arg === "--enable" || arg === "--disable") { - const value = args[index + 1]; - if (value !== undefined && !value.startsWith("-")) { - execArgs.push(arg, value); - index++; - } - } else if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) { - execArgs.push(arg); - } - } - - return execArgs; -}; diff --git a/apps/server/src/provider/Layers/codexUsageLimits.test.ts b/apps/server/src/provider/Layers/codexUsageLimits.test.ts deleted file mode 100644 index 8d310a9fff730ba750d51ac21e5b1a74b5a8bc18..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/codexUsageLimits.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import * as CodexErrors from "effect-codex-app-server/errors"; -import { describe, expect, it } from "vite-plus/test"; - -import { - codexRateLimitsFailureMessage, - codexRateLimitsToLimits, - codexRateLimitsToUpdate, - codexResetCreditsToContract, - codexUsageLimitMessage, - mergeCodexRateLimits, -} from "./codexUsageLimits.ts"; - -const checkedAt = "2026-07-18T10:00:00.000Z"; - -describe("codexRateLimitsToLimits", () => { - it("maps primary and secondary onto the session and weekly windows", () => { - expect( - codexRateLimitsToLimits({ - checkedAt, - snapshot: { - planType: "plus", - primary: { usedPercent: 12, resetsAt: 1_784_000_000, windowDurationMins: 300 }, - secondary: { usedPercent: 47, resetsAt: 1_784_500_000, windowDurationMins: 10080 }, - }, - }), - ).toEqual({ - checkedAt, - windows: [ - { - id: "primary", - kind: "session", - label: "Session", - usedPercent: 12, - windowDurationMins: 300, - resetsAt: "2026-07-14T03:33:20.000Z", - }, - { - id: "secondary", - kind: "weekly", - label: "Weekly", - usedPercent: 47, - windowDurationMins: 10080, - resetsAt: "2026-07-19T22:26:40.000Z", - }, - ], - }); - }); - - it("treats a lone duration-less primary as monthly on Free and Go", () => { - expect( - codexRateLimitsToLimits({ - checkedAt, - snapshot: { planType: "free", primary: { usedPercent: 80, resetsAt: null } }, - }).windows, - ).toEqual([ - { - id: "primary", - kind: "monthly", - label: "Monthly", - usedPercent: 80, - windowDurationMins: 43_200, - }, - ]); - }); - - it("selects the main Codex allowance and leaves Spark out", () => { - const spark = { - limitId: "codex_bengalfox", - primary: { usedPercent: 0, windowDurationMins: 300 }, - secondary: { usedPercent: 90, windowDurationMins: 10080 }, - }; - expect( - codexRateLimitsToLimits({ - checkedAt, - snapshot: spark, - rateLimitsByLimitId: { - codex_bengalfox: spark, - codex: { secondary: { usedPercent: 42, windowDurationMins: 10080 } }, - }, - }).windows, - ).toEqual([ - { - id: "secondary", - kind: "weekly", - label: "Weekly", - usedPercent: 42, - windowDurationMins: 10080, - }, - ]); - }); - - it.each([undefined, null, {}])( - "supports legacy reads with no bucket map: %j", - (rateLimitsByLimitId) => { - const snapshot = { primary: { usedPercent: 12, windowDurationMins: 300 } }; - expect(codexRateLimitsToLimits({ checkedAt, snapshot, rateLimitsByLimitId })).toEqual( - codexRateLimitsToLimits({ checkedAt, snapshot }), - ); - }, - ); - - it("does not show a model-specific legacy snapshot as the main allowance", () => { - expect( - codexRateLimitsToLimits({ - checkedAt, - snapshot: { - limitId: "codex_bengalfox", - secondary: { usedPercent: 90 }, - }, - }).windows, - ).toEqual([]); - }); -}); - -describe("codexRateLimitsToUpdate", () => { - it("carries only the windows the notification names", () => { - expect( - codexRateLimitsToUpdate({ - secondary: { usedPercent: 51, windowDurationMins: 10080 }, - }), - ).toEqual({ - windows: [ - { - id: "secondary", - kind: "weekly", - label: "Weekly", - usedPercent: 51, - windowDurationMins: 10080, - }, - ], - }); - expect(codexRateLimitsToUpdate({ planType: "plus" })).toBeUndefined(); - }); - - it("ignores Spark notifications so they cannot overwrite the main allowance", () => { - expect( - codexRateLimitsToUpdate({ - limitId: "codex_bengalfox", - primary: { usedPercent: 0, windowDurationMins: 300 }, - secondary: { usedPercent: 90, windowDurationMins: 10080 }, - }), - ).toBeUndefined(); - expect( - codexRateLimitsToUpdate({ - limitId: "codex", - secondary: { usedPercent: 42, windowDurationMins: 10080 }, - })?.windows, - ).toEqual([ - { - id: "secondary", - kind: "weekly", - label: "Weekly", - usedPercent: 42, - windowDurationMins: 10080, - }, - ]); - }); -}); - -describe("codexRateLimitsFailureMessage", () => { - it("keeps the JSON-RPC code and nothing else from a request failure", () => { - expect( - codexRateLimitsFailureMessage( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: - "failed to fetch codex rate limits: GET https://chatgpt.com/backend-api/wham/usage failed: 401 Unauthorized", - }), - ), - ).toBe("Codex could not read usage (JSON-RPC -32603)."); - }); - - it("phrases a dead process differently from a bad answer", () => { - expect( - codexRateLimitsFailureMessage(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })), - ).toBe("Codex exited before it could report usage."); - }); -}); - -describe("codexResetCreditsToContract", () => { - it("counts available credits and reports the soonest expiry", () => { - expect( - codexResetCreditsToContract({ - availableCount: 2, - credits: [ - { status: "available", expiresAt: 1_784_500_000 }, - { status: "redeemed", expiresAt: 1_700_000_000 }, - { status: "available", expiresAt: 1_784_000_000 }, - ], - }), - ).toEqual({ availableCount: 2, nextExpiresAt: "2026-07-14T03:33:20.000Z" }); - expect(codexResetCreditsToContract({ availableCount: 0 })).toEqual({ availableCount: 0 }); - expect(codexResetCreditsToContract(null)).toBeUndefined(); - }); - - it("rides along on the probe's limits", () => { - expect( - codexRateLimitsToLimits({ - checkedAt, - snapshot: { primary: { usedPercent: 5, windowDurationMins: 300 } }, - resetCredits: { availableCount: 1 }, - }).resetCredits, - ).toEqual({ availableCount: 1 }); - }); -}); - -describe("codexUsageLimitMessage", () => { - const at = "2026-01-01T00:00:00.000Z"; - const atSeconds = Date.parse(at) / 1000; - - it("names the exhausted window and the workspace's missing credits", () => { - expect( - codexUsageLimitMessage( - { - limitId: "codex", - rateLimitReachedType: "workspace_owner_credits_depleted", - primary: { usedPercent: 40, resetsAt: atSeconds + 3_600, windowDurationMins: 300 }, - secondary: { - usedPercent: 100, - resetsAt: atSeconds + 5 * 86_400 + 5 * 3_600, - windowDurationMins: 10_080, - }, - }, - at, - ), - ).toBe( - "Codex usage limit reached. The weekly limit resets in 5d 5h. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.", - ); - }); - - it("points a reached spend cap at the workspace owner", () => { - expect( - codexUsageLimitMessage( - { - limitId: "codex", - rateLimitReachedType: "workspace_member_usage_limit_reached", - primary: { - usedPercent: 100, - resetsAt: atSeconds + 3 * 3_600 + 20 * 60, - windowDurationMins: 300, - }, - }, - at, - ), - ).toBe( - "Codex usage limit reached. The session limit resets in 3h 20m. The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.", - ); - }); - - it("names no window when credits run out without one", () => { - expect( - codexUsageLimitMessage( - { limitId: "codex", rateLimitReachedType: "workspace_member_credits_depleted" }, - at, - ), - ).toBe( - "Codex usage limit reached. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.", - ); - }); - - it("says only what it knows without a snapshot", () => { - expect(codexUsageLimitMessage(undefined, at)).toBe( - "Codex usage limit reached. Send the message again once the limit resets.", - ); - }); -}); - -describe("mergeCodexRateLimits", () => { - it("keeps windows an update does not carry", () => { - const merged = mergeCodexRateLimits( - { - limitId: "codex", - planType: "business", - primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, - }, - { rateLimitReachedType: "rate_limit_reached" }, - ); - - expect(merged).toEqual({ - limitId: "codex", - planType: "business", - rateLimitReachedType: "rate_limit_reached", - primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, - }); - }); - - it("ignores a model-specific snapshot so it cannot replace the main allowance", () => { - const main = { - limitId: "codex", - primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, - }; - expect( - mergeCodexRateLimits(main, { - limitId: "spark", - primary: { usedPercent: 3, resetsAt: 1_800_000_000, windowDurationMins: 300 }, - }), - ).toBe(main); - }); -}); - -import { codexUsageLimitResetAt } from "./codexUsageLimits.ts"; - -describe("codexUsageLimitResetAt", () => { - it("waits for every exhausted window and never invents an unknown reset", () => { - expect( - codexUsageLimitResetAt({ - primary: { usedPercent: 100, resetsAt: 2000000000 }, - secondary: { usedPercent: 100, resetsAt: 2000100000 }, - }), - ).toBe("2033-05-19T07:20:00.000Z"); - expect(codexUsageLimitResetAt({ primary: { usedPercent: 100 } })).toBeNull(); - expect( - codexUsageLimitResetAt({ primary: { usedPercent: 50, resetsAt: 2000000000 } }), - ).toBeNull(); - }); -}); diff --git a/apps/server/src/provider/Layers/codexUsageLimits.ts b/apps/server/src/provider/Layers/codexUsageLimits.ts deleted file mode 100644 index f72ca02be835e02febf0a81e33730df5ca30a8bb..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/codexUsageLimits.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Codex subscription usage. The `account/rateLimits/read` response and the - * `account/rateLimits/updated` notification carry the same snapshot shape, so - * one mapper serves the status probe and the turn-driven update; both emit - * windows with the same ids so they merge onto the same rows. - * - * @module provider/Layers/codexUsageLimits - */ -import type { - ProviderUsageLimitsUpdate, - ServerProviderResetCredits, - ServerProviderUsageLimits, - ServerProviderUsageWindow, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Option from "effect/Option"; -import type * as CodexErrors from "effect-codex-app-server/errors"; - -import { clampPercent, makeUsageLimits } from "../providerUsageLimits.ts"; - -interface CodexRateLimitWindow { - readonly usedPercent: number; - readonly resetsAt?: number | null; - readonly windowDurationMins?: number | null; -} - -/** Structural view of the generated `RateLimitSnapshot`; both messages satisfy it. */ -export interface CodexRateLimitSnapshot { - readonly limitId?: string | null; - readonly planType?: string | null; - readonly rateLimitReachedType?: string | null; - readonly primary?: CodexRateLimitWindow | null; - readonly secondary?: CodexRateLimitWindow | null; -} - -/** Structural view of the read response's `rateLimitResetCredits`. */ -export interface CodexResetCreditsSummary { - readonly availableCount: number; - readonly credits?: ReadonlyArray<{ - readonly status: string; - readonly expiresAt?: number | null; - }> | null; -} - -const SESSION_MINS = 5 * 60; -const WEEK_MINS = 7 * 24 * 60; -const MONTH_MINS = 30 * 24 * 60; - -function isoFromEpochSeconds(value: number | null | undefined): string | undefined { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; - const dt = DateTime.make(value * 1000); - return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; -} - -function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] { - if (mins >= MONTH_MINS) return "monthly"; - if (mins >= WEEK_MINS) return "weekly"; - return "session"; -} - -function labelForKind(kind: ServerProviderUsageWindow["kind"]): string { - return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly"; -} - -/** - * `primary` / `secondary` are positions, not durations. Codex usually sends - * `windowDurationMins`; when it does not, paid plans expose the 5-hour and - * weekly pair and Free/Go expose one monthly allowance. - */ -function codexRateLimitsToWindows( - snapshot: CodexRateLimitSnapshot, -): ReadonlyArray { - // Show the main allowance only. Model-specific notifications (such as Spark) - // must not replace its primary/secondary rows. Older CLIs omit the limit id. - if (snapshot.limitId && snapshot.limitId !== "codex") return []; - const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go"; - const positions = [ - ["primary", snapshot.primary, isMonthlyPlan ? MONTH_MINS : SESSION_MINS], - ["secondary", snapshot.secondary, WEEK_MINS], - ] as const; - const windows: ServerProviderUsageWindow[] = []; - for (const [id, window, fallbackMins] of positions) { - if (!window || !Number.isFinite(window.usedPercent)) continue; - const windowDurationMins = - typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins; - const kind = kindForDuration(windowDurationMins); - const resetsAt = isoFromEpochSeconds(window.resetsAt); - windows.push({ - id, - kind, - label: labelForKind(kind), - usedPercent: clampPercent(window.usedPercent), - windowDurationMins, - ...(resetsAt ? { resetsAt } : {}), - }); - } - return windows; -} - -export function codexResetCreditsToContract( - summary: CodexResetCreditsSummary | null | undefined, -): ServerProviderResetCredits | undefined { - if (!summary) return undefined; - const expiries = (summary.credits ?? []) - .filter((credit) => credit.status === "available") - .map((credit) => credit.expiresAt) - .filter((value): value is number => typeof value === "number"); - const nextExpiresAt = - expiries.length > 0 ? isoFromEpochSeconds(Math.min(...expiries)) : undefined; - return { - availableCount: Math.max(0, summary.availableCount), - ...(nextExpiresAt ? { nextExpiresAt } : {}), - }; -} - -export function codexRateLimitsToLimits(input: { - readonly snapshot: CodexRateLimitSnapshot; - readonly rateLimitsByLimitId?: - | Readonly> - | null - | undefined; - readonly resetCredits?: CodexResetCreditsSummary | null | undefined; - readonly checkedAt: string; -}): ServerProviderUsageLimits { - const resetCredits = codexResetCreditsToContract(input.resetCredits); - // Select the main bucket explicitly; the legacy snapshot can name another limit. - const windows = codexRateLimitsToWindows(input.rateLimitsByLimitId?.codex ?? input.snapshot); - return { - ...makeUsageLimits({ - checkedAt: input.checkedAt, - windows, - }), - ...(resetCredits ? { resetCredits } : {}), - }; -} - -export function codexRateLimitsToUpdate( - snapshot: CodexRateLimitSnapshot, -): ProviderUsageLimitsUpdate | undefined { - const windows = codexRateLimitsToWindows(snapshot); - return windows.length > 0 ? { windows } : undefined; -} - -/** - * A bounded, client-safe reason for a failed `account/rateLimits/read`. The - * raw error is for the log; only the category and, for a JSON-RPC failure, - * the code reach the Limits view. - */ -export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerError): string { - switch (error._tag) { - case "CodexAppServerRequestError": - return `Codex could not read usage (JSON-RPC ${error.code}).`; - case "CodexAppServerSpawnError": - return "Codex could not be started to read usage."; - case "CodexAppServerProcessExitedError": - return "Codex exited before it could report usage."; - default: - return "Codex did not answer the usage request."; - } -} - -/** - * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a - * field the update omits keeps the value observed earlier in the session, so a - * later notification that only names the limit it reached must not drop the - * windows an earlier one carried. - */ -export function mergeCodexRateLimits( - previous: CodexRateLimitSnapshot | undefined, - update: CodexRateLimitSnapshot, -): CodexRateLimitSnapshot | undefined { - // Model-specific snapshots (such as Spark) describe a different allowance - // and must not overwrite the main one, the same rule the usage rows apply. - if (update.limitId && update.limitId !== "codex") return previous; - if (!previous) return update; - return { - ...previous, - ...(update.limitId !== undefined ? { limitId: update.limitId } : {}), - ...(update.planType !== undefined ? { planType: update.planType } : {}), - ...(update.rateLimitReachedType !== undefined - ? { rateLimitReachedType: update.rateLimitReachedType } - : {}), - ...(update.primary !== undefined ? { primary: update.primary } : {}), - ...(update.secondary !== undefined ? { secondary: update.secondary } : {}), - }; -} - -/** Coarse remaining wait, matching how the usage rows read: `5d 5h`, `3h 20m`, `12m`. */ -function formatCodexUsageLimitWait(waitMs: number): string { - const totalMinutes = Math.ceil(waitMs / 60_000); - const days = Math.floor(totalMinutes / (24 * 60)); - const hours = Math.floor((totalMinutes % (24 * 60)) / 60); - const minutes = totalMinutes % 60; - if (days > 0) return hours === 0 ? `${days}d` : `${days}d ${hours}h`; - if (hours === 0) return `${totalMinutes}m`; - return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; -} - -function codexUsageLimitNextStep(rateLimitReachedType: string | null | undefined): string { - switch (rateLimitReachedType) { - case "workspace_owner_credits_depleted": - case "workspace_member_credits_depleted": - return " The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets."; - case "workspace_owner_usage_limit_reached": - case "workspace_member_usage_limit_reached": - return " The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets."; - default: - return " Send the message again once the limit resets."; - } -} - -/** - * The message a usage-limit stop shows instead of the provider sentence, which - * on a Business workspace blames credits for a window that simply ran out. The - * window named is the exhausted one that has yet to reset, latest first; `atIso` - * is the stopping event's timestamp, not the wall clock. - */ -export function codexUsageLimitMessage( - snapshot: CodexRateLimitSnapshot | undefined, - atIso: string, -): string { - const atMs = Date.parse(atIso); - const windows = snapshot && Number.isFinite(atMs) ? codexRateLimitsToWindows(snapshot) : []; - let reset = ""; - let latestResetMs = Number.NEGATIVE_INFINITY; - for (const window of windows) { - if (window.usedPercent < 100 || !window.resetsAt) continue; - const resetMs = Date.parse(window.resetsAt); - if (!Number.isFinite(resetMs) || resetMs <= atMs || resetMs <= latestResetMs) continue; - latestResetMs = resetMs; - reset = ` The ${window.kind} limit resets in ${formatCodexUsageLimitWait(resetMs - atMs)}.`; - } - return `Codex usage limit reached.${reset}${codexUsageLimitNextStep(snapshot?.rateLimitReachedType)}`; -} - -/** All exhausted windows must reset before a continuation can run. */ -export function codexUsageLimitResetAt( - snapshot: CodexRateLimitSnapshot | undefined, -): string | null { - if (!snapshot) return null; - const windows = codexRateLimitsToWindows(snapshot).filter((window) => window.usedPercent >= 100); - if (windows.length === 0 || windows.some((window) => !window.resetsAt)) return null; - return windows.reduce( - (latest, window) => - latest === null || Date.parse(window.resetsAt!) > Date.parse(latest) - ? window.resetsAt! - : latest, - null, - ); -} diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.test.ts b/apps/server/src/provider/Layers/cursorUsageLimits.test.ts deleted file mode 100644 index 5ce2cee7fe8c2f4eb9cb9aeebf786219284bdbe6..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/cursorUsageLimits.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { NodeServices } from "@effect/platform-node"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import { HttpClient, HttpClientResponse } from "effect/http"; -import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts"; - -const withNodeServices = (effect: Effect.Effect) => - effect.pipe(Effect.provide(NodeServices.layer)); - -describe("Cursor usage limits", () => { - const checkedAt = "2026-09-16T00:00:00.000Z"; - - it("uses the advertised percentages and billing-cycle reset", () => { - const limits = cursorUsageResponseToLimits( - { - billingCycleEnd: "1789876386000", - planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 }, - }, - checkedAt, - ); - expect(limits.windows).toEqual( - expect.arrayContaining([ - { - id: "totalPercentUsed", - kind: "monthly", - label: "Overall", - usedPercent: 72.4, - resetsAt: "2026-09-20T03:53:06.000Z", - }, - { - id: "autoPercentUsed", - kind: "monthly", - label: "Cursor Models", - usedPercent: 69.5, - resetsAt: "2026-09-20T03:53:06.000Z", - }, - { - id: "apiPercentUsed", - kind: "monthly", - label: "Other Models", - usedPercent: 100, - resetsAt: "2026-09-20T03:53:06.000Z", - }, - ]), - ); - }); - - it("does not invent unused allowance for absent buckets", () => { - expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( - "unsupported", - ); - expect( - cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); - expect( - cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); - }); - - it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => - Effect.gen(function* () { - yield* withNodeServices( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - yield* fs.makeDirectory(path.join(directory, "cursor")); - yield* fs.writeFileString( - path.join(directory, "cursor", "auth.json"), - '{"accessToken":"instance-token"}', - ); - const client = HttpClient.make((request) => { - expect(request.url).toBe( - "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - ); - expect(request.method).toBe("POST"); - expect(request.headers.authorization).toBe("Bearer instance-token"); - expect(request.headers["connect-protocol-version"]).toBe("1"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), - ), - ); - }); - yield* fs.makeDirectory(path.join(directory, ".cursor")); - yield* fs.writeFileString( - path.join(directory, ".cursor", "auth.json"), - '{"accessToken":"instance-token"}', - ); - for (const platform of ["linux", "darwin"] as const) { - const limits = yield* readCursorUsageLimits( - { apiEndpoint: "https://cursor.example/" }, - { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, - ).pipe( - Effect.provideService(HostProcessPlatform, platform), - Effect.provideService(HttpClient.HttpClient, client), - ); - expect(limits.windows[0]?.usedPercent).toBe(42); - } - }).pipe(Effect.scoped), - ); - }), - ); - - it.effect( - "never reads stale files for keychain or memory logins, but accepts an explicit auth token", - () => - Effect.gen(function* () { - for (const platform of ["linux", "darwin"] as const) { - for (const token of [undefined, "explicit-token"]) { - const limits = yield* withNodeServices( - readCursorUsageLimits( - { apiEndpoint: "" }, - { - AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", - ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), - }, - false, - async () => { - throw new Error("must not read Keychain before opt-in"); - }, - ).pipe( - Effect.provideService(HostProcessPlatform, platform), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read an unrelated credential file"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(token).toBe("explicit-token"); - expect(request.headers.authorization).toBe("Bearer explicit-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ planUsage: { totalPercentUsed: 10 } }), - ), - ); - }), - ), - ), - ); - if (token) expect(limits.windows[0]?.usedPercent).toBe(10); - else expect(limits.unavailable?.reason).toBe("unsupported"); - } - } - }), - ); - - it.effect("reports failed requests without exposing credentials or response bodies", () => - Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response("private response", { status: 401 }), - ), - ), - ), - ), - ), - ); - expect(limits.unavailable).toEqual({ - reason: "probeFailed", - message: "Cursor could not read usage limits.", - }); - }), - ); - - it.effect("does not use a stored login for an explicit API key", () => - Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request usage")), - ), - ), - ); - expect(limits.unavailable?.reason).toBe("unsupported"); - }), - ); - - it.effect("reads the default macOS Cursor login from Keychain for limits", () => - Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( - Effect.provideService(HostProcessPlatform, "darwin"), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read a stale credential file"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(request.headers.authorization).toBe("Bearer keychain-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ planUsage: { totalPercentUsed: 42 } }), - ), - ); - }), - ), - ), - ); - expect(limits.windows[0]?.usedPercent).toBe(42); - }), - ); - - it.effect("reports a Keychain initialization failure without failing the provider refresh", () => - Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { - throw new Error("Keychain initialization failed"); - }).pipe( - Effect.provideService(HostProcessPlatform, "darwin"), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request limits without a login")), - ), - ), - ); - expect(limits.unavailable?.reason).toBe("probeFailed"); - }), - ); - - it.effect("does not read Keychain or send its token to a custom endpoint", () => - Effect.gen(function* () { - for (const [apiEndpoint, environment] of [ - ["http://localhost:3000", {}], - ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], - ["https://cursor-proxy.example", {}], - ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], - ] as const) { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { - throw new Error("must not read Keychain for a custom endpoint"); - }).pipe( - Effect.provideService(HostProcessPlatform, "darwin"), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), - ), - ), - ); - expect(limits.unavailable?.reason).toBe("unsupported"); - expect(limits.unavailable?.message).toContain("default Cursor endpoint"); - } - }), - ); -}); diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.ts b/apps/server/src/provider/Layers/cursorUsageLimits.ts deleted file mode 100644 index 470ed06446db29800c9bd0e21dd63aa3214defa7..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/cursorUsageLimits.ts +++ /dev/null @@ -1,151 +0,0 @@ -import * as NodeOS from "node:os"; -import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { - clampPercent, - makeUnavailableUsageLimits, - makeUsageLimits, -} from "../providerUsageLimits.ts"; -import { readMacCursorAccessToken } from "../cursorKeychainToken.ts"; - -const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); -const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; -const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); -const CursorUsageResponse = Schema.Struct({ - billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), - planUsage: Schema.optional( - Schema.Struct({ - totalPercentUsed: Schema.optional(Schema.Number), - autoPercentUsed: Schema.optional(Schema.Number), - apiPercentUsed: Schema.optional(Schema.Number), - }), - ), -}); - -/** Cursor's dashboard percentages include bonus usage; spend / limit does not. */ -export function cursorUsageResponseToLimits( - response: typeof CursorUsageResponse.Type, - checkedAt: string, -) { - const reset = DateTime.make(Number(response.billingCycleEnd)); - const resetsAt = - Number(response.billingCycleEnd) > 0 && Option.isSome(reset) - ? DateTime.formatIso(reset.value) - : undefined; - const windows: ServerProviderUsageWindow[] = []; - if (response.planUsage) { - for (const { id, label } of CURSOR_USAGE_WINDOWS) { - const usedPercent = response.planUsage[id]; - if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; - windows.push({ - id, - kind: "monthly", - label, - usedPercent: clampPercent(usedPercent), - ...(resetsAt ? { resetsAt } : {}), - }); - } - } - return windows.length > 0 - ? makeUsageLimits({ checkedAt, windows }) - : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); -} - -export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( - settings: Pick, - environment: NodeJS.ProcessEnv = process.env, - allowKeychain = false, - keychainToken: () => Promise = readMacCursorAccessToken, -) { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - return yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const endpoint = ( - settings.apiEndpoint?.trim() || - environment.CURSOR_API_ENDPOINT?.trim() || - DEFAULT_CURSOR_API_ENDPOINT - ).replace(/\/$/, ""); - let token = environment.CURSOR_AUTH_TOKEN?.trim(); - // An explicit API key can name a different account from the stored login. - if (!token && environment.CURSOR_API_KEY?.trim()) { - return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - } - const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; - if (!token && credentialStore === "memory") { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", - }); - } - if (!token && platform === "darwin" && credentialStore !== "file") { - if (!allowKeychain) { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Enable Cursor account usage in T3 Code to read its Keychain login.", - }); - } - if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", - }); - } - token = (yield* Effect.tryPromise(keychainToken))?.trim(); - } else if (!token) { - const home = - (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); - const directory = - platform === "win32" - ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") - : platform === "darwin" - ? path.join(home, ".cursor") - : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); - const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - Effect.flatMap(decodeCredentials), - ); - token = credentials.accessToken?.trim(); - } - if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - const client = yield* HttpClient.HttpClient; - const response = yield* client.execute( - HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( - HttpClientRequest.bearerToken(token), - HttpClientRequest.setHeaders({ - "connect-protocol-version": "1", - "x-cursor-client-type": "cli", - }), - HttpClientRequest.bodyJsonUnsafe({}), - ), - ); - const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( - yield* HttpClientResponse.filterStatusOk(response), - ); - return cursorUsageResponseToLimits(body, checkedAt); - }).pipe( - Effect.timeout("10 seconds"), - Effect.orElseSucceed(() => - makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "Cursor could not read usage limits.", - }), - ), - ); -}); diff --git a/apps/server/src/provider/Layers/grokUsageLimits.ts b/apps/server/src/provider/Layers/grokUsageLimits.ts deleted file mode 100644 index 27ae53bb6638b6d2d14b4a0f28342f7cb4bde893..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/grokUsageLimits.ts +++ /dev/null @@ -1,175 +0,0 @@ -import * as NodeOS from "node:os"; -import type { ServerProviderUsageWindow } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { - clampPercent, - makeUnavailableUsageLimits, - makeUsageLimits, -} from "../providerUsageLimits.ts"; - -const GrokCredentials = Schema.Record( - Schema.String, - Schema.Struct({ - key: Schema.optional(Schema.String), - auth_mode: Schema.optional(Schema.String), - email: Schema.optional(Schema.String), - }), -); -const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); -const GrokUsageResponse = Schema.Struct({ - config: Schema.optional( - Schema.Struct({ - creditUsagePercent: Schema.optional(Schema.Number), - currentPeriod: Schema.optional( - Schema.Struct({ - type: Schema.optional(Schema.String), - end: Schema.optional(Schema.String), - }), - ), - }), - ), -}); - -export function grokUsageResponseToLimits( - response: typeof GrokUsageResponse.Type, - checkedAt: string, -) { - const usedPercent = response.config?.creditUsagePercent; - if (usedPercent === undefined || !Number.isFinite(usedPercent)) { - // A billing read that succeeded but carries no percentage is an account - // with nothing metered yet, not one that can never report: xAI omits the - // field entirely (rather than sending 0) until usage registers, then fills - // it in. Calling that `unsupported` would strand the account — the Limits - // view drops unsupported entries and deliberately mutes their notice, so a - // freshly signed-in Grok account would vanish with no explanation until it - // happened to be used, and `applyUsageLimitsUpdate` would refuse the - // mid-turn windows that could have recovered it. - return makeUsageLimits({ checkedAt, windows: [] }); - } - const period = response.config?.currentPeriod; - const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, ""); - const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other"; - const reset = period?.end ? DateTime.make(period.end) : Option.none(); - const window: ServerProviderUsageWindow = { - id: "subscription", - kind, - label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription", - usedPercent: clampPercent(usedPercent), - ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}), - }; - return makeUsageLimits({ checkedAt, windows: [window] }); -} - -/** - * The grok.com login the CLI uses by default, or undefined when the CLI is - * configured to pick another account, endpoint, or an API key. - */ -const readGrokCredential = Effect.fn("readGrokCredential")(function* ( - environment: NodeJS.ProcessEnv, -) { - // T3's ACP adapter explicitly selects API-key auth when this variable is set. - if (environment.XAI_API_KEY?.trim()) return undefined; - // Alternate auth deployments can select another scope or account from the same file. - if ( - [ - "GROK_OIDC_ISSUER", - "GROK_OIDC_CLIENT_ID", - "GROK_OAUTH2_ISSUER", - "GROK_OAUTH2_CLIENT_ID", - "GROK_OAUTH2_PRINCIPAL_TYPE", - "GROK_OAUTH2_PRINCIPAL_ID", - "GROK_AUTH_PROVIDER_COMMAND", - "GROK_LOCAL_AUTH", - "GROK_CLI_CHAT_PROXY_BASE_URL", - "GROK_MODELS_BASE_URL", - "GROK_CONFIG", - "GROK_CONFIG_PATH", - ].some((name) => environment[name]?.trim()) - ) { - return undefined; - } - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = - environment.GROK_HOME?.trim() || - path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); - for (const configPath of [ - path.join(home, "config.toml"), - path.join(home, "managed_config.toml"), - path.join(home, "requirements.toml"), - "/etc/grok/managed_config.toml", - "/etc/grok/requirements.toml", - ]) { - const config = yield* fs.readFileString(configPath).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), - }), - ); - // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. - if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { - return undefined; - } - } - const contents = - environment.GROK_AUTH?.trim() || - (yield* fs.readFileString(path.join(home, "auth.json")).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - )); - const credentials = yield* decodeCredentials(contents); - // Never pick an arbitrary account from other deployments stored in the same file. - const credential = - credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? - credentials["https://accounts.x.ai/sign-in"]; - return credential?.auth_mode === "api_key" ? undefined : credential; -}); - -/** - * Reads the default grok.com login once and reports its usage limits along with - * its email, so the email always names the account whose quota was read. - */ -export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( - environment: NodeJS.ProcessEnv = process.env, -) { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const probeFailed = makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "Grok could not read usage limits.", - }); - const credential = yield* Effect.option( - readGrokCredential(environment).pipe(Effect.timeout("10 seconds")), - ); - if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed }; - const email = credential.value?.email?.trim() || undefined; - const token = credential.value?.key?.trim(); - if (!token) { - return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }; - } - // A failed quota request still knows which account it asked about. - const usageLimits = yield* Effect.gen(function* () { - const client = yield* HttpClient.HttpClient; - const response = yield* client.execute( - HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe( - HttpClientRequest.bearerToken(token), - ), - ); - const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)( - yield* HttpClientResponse.filterStatusOk(response), - ); - return grokUsageResponseToLimits(body, checkedAt); - }).pipe( - Effect.timeout("10 seconds"), - Effect.orElseSucceed(() => probeFailed), - ); - return { email, usageLimits }; -}); diff --git a/apps/server/src/provider/Layers/openCodeUsageLimits.ts b/apps/server/src/provider/Layers/openCodeUsageLimits.ts deleted file mode 100644 index f2b0d6ffd21a3d8c77654caeeb3819fe216c098c..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/openCodeUsageLimits.ts +++ /dev/null @@ -1,119 +0,0 @@ -import * as NodeOS from "node:os"; -import * as NodeCrypto from "node:crypto"; - -import type { ServerProviderUsageWindow } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; - -import { - clampPercent, - makeUnavailableUsageLimits, - makeUsageLimits, -} from "../providerUsageLimits.ts"; - -const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); -const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); -const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); -const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); -const UsageWindow = Schema.Struct({ - percent: Schema.Finite, - resetsAt: Schema.DateTimeUtcFromString, -}); -const UsageResponse = Schema.Struct({ - usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), -}); - -/** External OpenCode servers own their credentials; never read the host's account for them. */ -export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { - readonly enabled: boolean; - readonly serverUrl: string; - readonly environment: NodeJS.ProcessEnv; -}) { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - if (!input.enabled || input.serverUrl.trim()) return unsupported; - - return yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env = input.environment; - const dataHome = - env.XDG_DATA_HOME || - path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share"); - const authPath = path.join(dataHome, "opencode", "auth.json"); - const contents = - env.OPENCODE_AUTH_CONTENT || - (yield* fs.readFileString(authPath).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - )); - const auth = yield* decodeAuthFile(contents); - const apiAuth = decodeApiAuth(auth["opencode-go"]); - // OpenCode overlays stored API credentials after environment credentials. - const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); - if (!apiKey) return unsupported; - - const client = yield* HttpClient.HttpClient; - const response = yield* client.execute( - HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe( - HttpClientRequest.bearerToken(apiKey), - ), - ); - // A valid Zen key can exist without a Go subscription. - if (response.status === 403) return unsupported; - const body = yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)), - ); - const windows: ServerProviderUsageWindow[] = [ - { - id: "go_rolling", - kind: "session", - label: "Go · Session", - windowDurationMins: 5 * 60, - usedPercent: clampPercent(body.usage.rolling.percent), - resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt), - }, - { - id: "go_weekly", - kind: "weekly", - label: "Go · Weekly", - windowDurationMins: 7 * 24 * 60, - usedPercent: clampPercent(body.usage.weekly.percent), - resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt), - }, - { - id: "go_monthly", - kind: "monthly", - label: "Go · Monthly", - usedPercent: clampPercent(body.usage.monthly.percent), - resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), - }, - ]; - return { - ...makeUsageLimits({ checkedAt, windows }), - // Go's usage response has no account ID. An unkeyed hash matches across - // environments without a shared secret. It permits offline guesses, but - // Go keys are randomly generated. - credentialFingerprint: NodeCrypto.createHash("sha256") - .update("opencode-go\0") - .update(apiKey) - .digest("hex"), - }; - }).pipe( - Effect.timeout("5 seconds"), - Effect.orElseSucceed(() => - makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "OpenCode Go could not read usage.", - }), - ), - ); -}); diff --git a/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts b/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts deleted file mode 100644 index b6474bf2d9ec400ae07f3020c3f0cdcf521189d9..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { thinkingCapabilitiesForPiModel } from "./piThinkingCapabilities.ts"; - -describe("thinkingCapabilitiesForPiModel", () => { - it("gives every choice its real level id and tags Pi's default", () => { - const capabilities = thinkingCapabilitiesForPiModel( - { - reasoning: true, - thinkingLevelMap: { off: null, xhigh: "extra_high", max: null }, - }, - "xhigh", - ); - const descriptors = capabilities.optionDescriptors ?? []; - const thinking = descriptors[0]; - assert.equal(thinking?.id, "thinking"); - assert.equal(thinking?.type, "select"); - if (thinking?.type !== "select") return; - assert.deepEqual( - thinking.options.map((option) => [option.id, option.label, option.isDefault === true]), - [ - ["minimal", "Minimal", false], - ["low", "Low", false], - ["medium", "Medium", false], - ["high", "High", false], - ["xhigh", "Extra High", true], - ], - ); - }); - - it("clamps Pi's default to each model's supported levels", () => { - const capabilities = thinkingCapabilitiesForPiModel( - { - reasoning: true, - thinkingLevelMap: { xhigh: "extra_high", max: null }, - }, - "max", - ); - const thinking = capabilities.optionDescriptors?.[0]; - assert.equal(thinking?.type, "select"); - if (thinking?.type !== "select") return; - assert.deepInclude(thinking.options, { - id: "xhigh", - label: "Extra High", - isDefault: true, - }); - assert.notInclude( - thinking.options.map((option) => option.id), - "max", - ); - }); -}); diff --git a/apps/server/src/provider/Layers/piThinkingCapabilities.ts b/apps/server/src/provider/Layers/piThinkingCapabilities.ts deleted file mode 100644 index 0df30c7fe99cbf5002047d041035032f7e69168b..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/piThinkingCapabilities.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; -import * as Predicate from "effect/Predicate"; - -/** - * Pi's full thinking ladder. Extra High (`xhigh`) and Max are opt-in per - * model via `thinkingLevelMap`; advertising them globally makes - * `set_thinking_level` fail on models that lack them. - */ -const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; - -export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; - -const PI_THINKING_LEVEL_LABELS: Record = { - off: "Off", - minimal: "Minimal", - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - max: "Max", -}; - -export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); - -export function thinkingCapabilitiesForPiModel( - model: unknown, - defaultThinkingLevel: unknown, -): ModelCapabilities { - const levels = supportedPiThinkingLevelsFromModel(model); - if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES; - const defaultLevel = clampPiThinkingLevel(defaultThinkingLevel, levels); - return createModelCapabilities({ - optionDescriptors: [ - { - id: "thinking", - label: "Thinking", - type: "select", - options: levels.map((level): ProviderOptionChoice => ({ - id: level, - label: PI_THINKING_LEVEL_LABELS[level], - ...(level === defaultLevel ? { isDefault: true } : {}), - })), - }, - ], - }); -} - -/** Mirrors `@earendil-works/pi-ai` `clampThinkingLevel`. */ -function clampPiThinkingLevel( - input: unknown, - availableLevels: ReadonlyArray, -): PiThinkingLevel | undefined { - if (typeof input !== "string") return undefined; - const requestedIndex = PI_THINKING_LEVELS.findIndex((level) => level === input); - if (requestedIndex === -1) return undefined; - const exact = availableLevels.find((level) => level === input); - if (exact !== undefined) return exact; - for (let index = requestedIndex + 1; index < PI_THINKING_LEVELS.length; index += 1) { - const higher = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); - if (higher !== undefined) return higher; - } - for (let index = requestedIndex - 1; index >= 0; index -= 1) { - const lower = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); - if (lower !== undefined) return lower; - } - return availableLevels[0]; -} - -/** - * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`. - * - * A reasoning model always exposes off through high unless a map entry is - * `null`. Extra High and Max appear only when the map has a non-null entry. - */ -function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray { - if (recordField(model, "reasoning") !== true) return []; - const thinkingLevelMap = thinkingLevelMapFromModel(model); - return PI_THINKING_LEVELS.filter((level) => { - const mapped = thinkingLevelMap?.[level]; - if (mapped === null) return false; - if (level === "xhigh" || level === "max") return mapped !== undefined; - return true; - }); -} - -function thinkingLevelMapFromModel(model: unknown): Record | undefined { - const value = recordField(model, "thinkingLevelMap"); - if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; - return value as Record; -} - -function recordField(input: unknown, key: string): unknown { - return Predicate.isObject(input) ? input[key] : undefined; -} diff --git a/apps/server/src/provider/Layers/resetCreditCoordinator.test.ts b/apps/server/src/provider/Layers/resetCreditCoordinator.test.ts deleted file mode 100644 index 88de498d43dc3578e4fdc8d71b7d5ac673072dcb..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/resetCreditCoordinator.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Ref from "effect/Ref"; - -import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; - -describe("ResetCreditCoordinator", () => { - it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () => - Effect.gen(function* () { - const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; - const keys = yield* Ref.make>([]); - const attempts = yield* Ref.make(0); - const consume = (key: string) => - Effect.gen(function* () { - yield* Ref.update(keys, (seen) => [...seen, key]); - const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1); - if (attempt === 1) return yield* Effect.fail("timed out" as const); - return "reset" as const; - }); - - const first = yield* redeem("acct", consume).pipe(Effect.result); - assert.isTrue(first._tag === "Failure"); - const second = yield* redeem("acct", consume); - assert.strictEqual(second, "reset"); - // A fresh redemption after success must be a fresh attempt. - yield* redeem("acct", consume); - - const seen = yield* Ref.get(keys); - assert.strictEqual(seen.length, 3); - assert.strictEqual(seen[0], seen[1]); - assert.notStrictEqual(seen[1], seen[2]); - }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), - ); - - it.effect("starts a fresh attempt after a settled failure", () => - Effect.gen(function* () { - const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; - const keys = yield* Ref.make>([]); - const consume = (key: string) => - Ref.update(keys, (seen) => [...seen, key]).pipe( - Effect.andThen(Effect.fail("cooldown" as const)), - ); - const isSettled = (error: "cooldown") => error === "cooldown"; - - yield* redeem("acct", consume, isSettled).pipe(Effect.result); - yield* redeem("acct", consume, isSettled).pipe(Effect.result); - - const seen = yield* Ref.get(keys); - assert.strictEqual(seen.length, 2); - assert.notStrictEqual(seen[0], seen[1]); - }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), - ); - - it.effect("serialises concurrent redemptions on the same account, not per caller", () => - Effect.gen(function* () { - const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; - const release = yield* Deferred.make(); - const inFlight = yield* Ref.make(0); - const peak = yield* Ref.make(0); - const consume = () => - Effect.gen(function* () { - const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); - yield* Ref.update(peak, (p) => Math.max(p, now)); - yield* Deferred.await(release); - yield* Ref.update(inFlight, (n) => n - 1); - return "reset" as const; - }); - - // Two instances of the same account redeem at once. - const a = yield* redeem("acct", consume).pipe(Effect.forkChild); - const b = yield* redeem("acct", consume).pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(a); - yield* Fiber.join(b); - - assert.strictEqual(yield* Ref.get(peak), 1); - }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), - ); - - it.effect("keeps different accounts independent", () => - Effect.gen(function* () { - const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; - const release = yield* Deferred.make(); - const peak = yield* Ref.make(0); - const inFlight = yield* Ref.make(0); - const consume = () => - Effect.gen(function* () { - const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); - yield* Ref.update(peak, (p) => Math.max(p, now)); - yield* Deferred.await(release); - return "reset" as const; - }); - const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild); - const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(a); - yield* Fiber.join(b); - assert.strictEqual(yield* Ref.get(peak), 2); - }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), - ); -}); diff --git a/apps/server/src/provider/Layers/resetCreditCoordinator.ts b/apps/server/src/provider/Layers/resetCreditCoordinator.ts deleted file mode 100644 index c481624e7f40913557ec6f9116ad8d342146093b..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/resetCreditCoordinator.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Redeeming a reset credit is an account-level action: instances that share - * the directory holding a provider's login share the credit, so their - * redemptions must serialise on that directory, not the instance. This - * service keeps one lock and one pending idempotency key per account key so - * overlapping confirmations from any instance queue rather than spending two - * credits, and a retry after a timeout re-sends the same attempt. - * - * @module provider/Layers/resetCreditCoordinator - */ -import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import type * as PlatformError from "effect/PlatformError"; -import * as Ref from "effect/Ref"; -import * as Semaphore from "effect/Semaphore"; - -interface AccountRedemptionState { - readonly lock: Semaphore.Semaphore; - readonly pendingKey: Ref.Ref; -} - -export class ResetCreditCoordinator extends Context.Service< - ResetCreditCoordinator, - { - /** - * Run `consume` under the account's lock with a stable idempotency key. - * The key is cleared when the provider reports an outcome, or when - * `isSettled` says a failure was a final answer (such as a cooldown). - * Any other failure (timeout included) keeps it so the next attempt is - * the same attempt. - */ - readonly redeem: ( - accountKey: string, - consume: (idempotencyKey: string) => Effect.Effect, - isSettled?: (error: E) => boolean, - ) => Effect.Effect; - } ->()("t3/provider/Layers/resetCreditCoordinator") {} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const statesRef = yield* Ref.make>(new Map()); - - // Get-or-create through one Ref.modify so two first redemptions for the - // same account cannot each install their own lock. - const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) { - const existing = (yield* Ref.get(statesRef)).get(accountKey); - if (existing) return existing; - const candidate = { - lock: yield* Semaphore.make(1), - pendingKey: yield* Ref.make(null), - }; - return yield* Ref.modify(statesRef, (states) => { - const current = states.get(accountKey); - if (current) return [current, states] as const; - const next = new Map(states); - next.set(accountKey, candidate); - return [candidate, next] as const; - }); - }); - - const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) => - Effect.gen(function* () { - const state = yield* stateFor(accountKey); - return yield* state.lock.withPermits(1)( - Effect.gen(function* () { - const existing = yield* Ref.get(state.pendingKey); - const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4); - yield* Ref.set(state.pendingKey, idempotencyKey); - const outcome = yield* consume(idempotencyKey).pipe( - Effect.tapError((error) => - isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, - ), - ); - yield* Ref.set(state.pendingKey, null); - return outcome; - }), - ); - }); - - return { redeem } satisfies ResetCreditCoordinator["Service"]; -}); - -export const layer = Layer.effect(ResetCreditCoordinator, make); - -/** - * Self-contained for tests: a counter-backed Crypto so keys are deterministic - * and distinct without the platform layer. - */ -export const layerTest = Layer.effect( - ResetCreditCoordinator, - Effect.gen(function* () { - let counter = 0; - return yield* make.pipe( - Effect.provideService( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => { - counter += 1; - return new Uint8Array(size).fill(counter); - }, - digest: (_algorithm, data) => Effect.succeed(data), - }), - ), - ); - }), -); diff --git a/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs b/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs deleted file mode 100755 index 57fc1795cb65e3601198f3abef3d7ad37ee83f43..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env node -import * as NodeFS from "node:fs"; -import * as NodeReadline from "node:readline"; -const args = process.argv.slice(2); -const mcpConfigIndex = args.indexOf("--mcp-config"); -const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined; -let mcpConfig; -if (rawMcpConfig) { - const contents = NodeFS.existsSync(rawMcpConfig) - ? NodeFS.readFileSync(rawMcpConfig, "utf8") - : rawMcpConfig; - try { - mcpConfig = JSON.parse(contents); - } catch { - mcpConfig = contents; - } -} -NodeFS.writeFileSync( - process.env.T3_PROBE_INVOCATION_PATH, - JSON.stringify({ - args, - cwd: process.cwd(), - connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS, - mcpConfig, - }), -); -const lines = NodeReadline.createInterface({ input: process.stdin }); -lines.on("line", (line) => { - const message = JSON.parse(line); - if (message.type !== "control_request") return; - const reply = (response) => - process.stdout.write( - JSON.stringify({ - type: "control_response", - response: { subtype: "success", request_id: message.request_id, response }, - }) + "\n", - ); - if (message.request?.subtype === "initialize") { - reply({ - commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], - agents: [], - output_style: "default", - available_output_styles: ["default"], - models: [], - account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, - }); - } - // The probe follows initialize with get_usage on the same process. - if (message.request?.subtype === "get_usage") { - reply({ - session: {}, - subscription_type: "pro", - rate_limits_available: true, - rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, - behaviors: null, - }); - } -}); -// Stay alive for follow-up control requests, but never outlive the -// parent: the probe aborts the SDK without awaiting the child, so an -// unconditional interval would strand this process until reboot. -const keepAlive = setInterval(() => {}, 1_000); -lines.on("close", () => { - clearInterval(keepAlive); - process.exit(0); -}); diff --git a/apps/server/src/provider/Layers/testing/ProviderInstanceRegistryLive.fixture.mjs b/apps/server/src/provider/Layers/testing/ProviderInstanceRegistryLive.fixture.mjs deleted file mode 100755 index 054ca04703e5149b7219d45708e0c3ed824557bf..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/testing/ProviderInstanceRegistryLive.fixture.mjs +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env node -import * as NodeFS from "node:fs"; -import * as NodeReadline from "node:readline"; -if (process.argv.includes("--version")) { - process.stdout.write("claude 2.1.219\n"); - process.exit(0); -} -const lines = NodeReadline.createInterface({ input: process.stdin }); -lines.on("line", (line) => { - const message = JSON.parse(line); - if (message.type !== "control_request") return; - if (message.request?.subtype === "get_usage") { - const marker = process.env.T3_CLAUDE_RESET_MARKER; - if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && NodeFS.existsSync(marker)) { - process.stdout.write( - JSON.stringify({ - type: "control_response", - response: { subtype: "error", request_id: message.request_id, error: "usage failed" }, - }) + "\n", - ); - return; - } - process.stdout.write( - JSON.stringify({ - type: "control_response", - response: { - subtype: "success", - request_id: message.request_id, - response: { - session: {}, - subscription_type: "pro", - rate_limits_available: true, - rate_limits: { - five_hour: { - utilization: marker && NodeFS.existsSync(marker) ? 0 : 100, - resets_at: null, - }, - }, - }, - }, - }) + "\n", - ); - return; - } - if (message.request?.subtype !== "initialize") return; - process.stdout.write( - JSON.stringify({ - type: "control_response", - response: { - subtype: "success", - request_id: message.request_id, - response: { - commands: [], - agents: [], - models: [], - output_style: "default", - available_output_styles: ["default"], - account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" }, - }, - }, - }) + "\n", - ); -}); -// Stay alive for follow-up control requests, but never outlive the -// parent: the probe aborts the SDK without awaiting the child, so an -// unconditional interval would strand this process until reboot. -const keepAlive = setInterval(() => {}, 1_000); -lines.on("close", () => { - clearInterval(keepAlive); - process.exit(0); -}); diff --git a/apps/server/src/provider/OpenCodeProvider.test.ts b/apps/server/src/provider/OpenCodeProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7cfb54502df50f50cb536e1c5ebfa0d63096876 --- /dev/null +++ b/apps/server/src/provider/OpenCodeProvider.test.ts @@ -0,0 +1,873 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeCrypto from "node:crypto"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { beforeEach } from "vite-plus/test"; + +import { OpenCodeSettings } from "@t3tools/contracts"; +import * as ServerConfig from "../config.ts"; +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; +import { + checkOpenCodeProviderStatus, + loadOpenCode2Workspace, + makeOpenCode2ModelLoader, + type OpenCode2Model, + type OpenCode2Workspace, + openCode2CommandsToServerProviderSlashCommands, + openCode2SkillsToServerProviderSkills, + openCodeCommandsToServerProviderSlashCommands, +} from "./OpenCodeProvider.ts"; +import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; +import { probeOpenCodeRuntime } from "./opencodeVersionProbe.ts"; +import { + OPENCODE_1_RESPONSES, + OPENCODE_2_RESPONSES, + replayOpenCodeServer, +} from "./testFixtures/opencodeProbeResponses.ts"; +const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); + +const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; + +it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => + Effect.gen(function* () { + const resetsAt = "2026-09-17T12:00:00.000Z"; + const limits = yield* readOpenCodeGoUsageLimits({ + enabled: true, + serverUrl: "", + environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" }, + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: (path) => { + NodeAssert.equal(path, "/instance/data/opencode/auth.json"); + return Effect.succeed( + JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }), + ); + }, + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage"); + NodeAssert.equal(request.headers.authorization, "Bearer instance-key"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + usage: { + rolling: { percent: 0, resetsAt }, + weekly: { percent: 10, resetsAt }, + monthly: { percent: 125, resetsAt }, + }, + }), + ), + ); + }), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.equal(limits.unavailable, undefined); + NodeAssert.equal( + limits.credentialFingerprint, + NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"), + ); + NodeAssert.deepEqual( + limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ + kind, + usedPercent, + reset, + })), + [ + { kind: "session", usedPercent: 0, reset: resetsAt }, + { kind: "weekly", usedPercent: 10, reset: resetsAt }, + { kind: "monthly", usedPercent: 100, reset: resetsAt }, + ], + ); + }), +); + +it.effect("does not read local credentials for external or disabled OpenCode instances", () => + Effect.gen(function* () { + for (const settings of [ + { enabled: true, serverUrl: "https://remote.example" }, + { enabled: false, serverUrl: "" }, + ]) { + const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("unexpected credential read"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected usage request")), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.equal(limits.unavailable?.reason, "unsupported"); + } + }), +); + +it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () => + Effect.gen(function* () { + for (const [status, reason] of [ + [403, "unsupported"], + [401, "probeFailed"], + [200, "probeFailed"], + ] as const) { + const limits = yield* readOpenCodeGoUsageLimits({ + enabled: true, + serverUrl: "", + environment: { + OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}', + }, + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("inline credentials must bypass disk"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))), + ), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.equal(limits.unavailable?.reason, reason); + NodeAssert.equal(limits.credentialFingerprint, undefined); + NodeAssert.deepEqual(limits.windows, []); + } + }), +); + +/** + * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag + * are deleted. The snapshot-producing logic they wrapped now lives in the + * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which + * drivers call directly when building their per-instance snapshot + * `ServerProviderShape`. Tests mirror that shape: build a settings payload, + * invoke the check, assert on the returned snapshot. + */ + +const runtimeMock = { + state: { + runVersionError: null as Error | null, + runVersionPending: false, + versionStdout: DEFAULT_VERSION_STDOUT, + inventoryError: null as Error | null, + connectionError: null as Error | null, + inventoryCwd: null as string | null, + closeCalls: 0, + sdkClientInputs: [] as Array<{ + baseUrl: string; + directory: string; + serverPassword?: string; + }>, + inventory: { + providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, + agents: [] as unknown[], + skills: [] as unknown[], + } as unknown, + }, + reset() { + this.state.runVersionError = null; + this.state.runVersionPending = false; + this.state.versionStdout = DEFAULT_VERSION_STDOUT; + this.state.inventoryError = null; + this.state.connectionError = null; + this.state.inventoryCwd = null; + this.state.closeCalls = 0; + this.state.sdkClientInputs.length = 0; + this.state.inventory = { + providerList: { connected: [], all: [] as unknown[], default: {} }, + agents: [] as unknown[], + skills: [] as unknown[], + }; + }, +}; + +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { + startOpenCodeServerProcess: ({ serverPassword, environment }) => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls += 1; + }), + ); + const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ + external: false, + ...(serverPassword !== undefined ? { serverPassword } : {}), + ...(environment !== undefined ? { environment } : {}), + }); + return { + url: "http://127.0.0.1:4301", + ...(effectiveServerPassword !== undefined + ? { serverPassword: effectiveServerPassword } + : {}), + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: ({ serverUrl, serverPassword }) => + Effect.gen(function* () { + if (runtimeMock.state.connectionError) { + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "global.health", + detail: runtimeMock.state.connectionError.message, + cause: runtimeMock.state.connectionError, + }); + } + if (!serverUrl) { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls += 1; + }), + ); + } + return { + url: serverUrl ?? "http://127.0.0.1:4301", + ...(serverPassword ? { serverPassword } : {}), + version: "1.14.19", + exitCode: null, + external: Boolean(serverUrl), + }; + }), + runOpenCodeCommand: () => + runtimeMock.state.runVersionPending + ? Effect.never + : runtimeMock.state.runVersionError + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "runOpenCodeCommand", + detail: runtimeMock.state.runVersionError.message, + cause: runtimeMock.state.runVersionError, + }), + ) + : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + createOpenCodeSdkClient: (input) => { + runtimeMock.state.sdkClientInputs.push(input); + return {} as unknown as ReturnType< + OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"] + >; + }, + loadOpenCodeInventory: () => + runtimeMock.state.inventoryError + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: runtimeMock.state.inventoryError.message, + cause: runtimeMock.state.inventoryError, + }), + ) + : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory), + loadInventoryFromCli: ({ cwd }) => { + runtimeMock.state.inventoryCwd = cwd; + return runtimeMock.state.inventoryError + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: runtimeMock.state.inventoryError.message, + cause: runtimeMock.state.inventoryError, + }), + ) + : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory); + }, + loadOpenCodeSkills: () => Effect.succeed([]), + loadSkillsFromCli: () => Effect.succeed([]), +}; + +beforeEach(() => { + runtimeMock.reset(); +}); + +it("keeps native and MCP commands while preserving compaction and separate skills", () => { + NodeAssert.deepEqual( + openCodeCommandsToServerProviderSlashCommands([ + { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] }, + { name: "review", source: "command", hints: [] }, + { name: "compact", source: "command", hints: [] }, + { name: "skill", source: "skill", hints: [] }, + { name: "mcp:search", source: "mcp", hints: ["query"] }, + ]).slice(1), + [ + { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } }, + { name: "mcp:search", input: { hint: "query" } }, + ], + ); +}); + +const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), +); + +const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSettings => + decodeOpenCodeSettings({ + enabled: true, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + ...overrides, + }); + +const checkProvider = Effect.fn("checkProvider")(function* ( + settings: OpenCodeSettings, + cwd = process.cwd(), + environment?: NodeJS.ProcessEnv, + server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword), + openCode2Models: Effect.Effect< + ReadonlyArray, + OpenCodeRuntime.OpenCodeRuntimeError + > = Effect.succeed([]), +) { + return yield* Effect.scoped( + Effect.gen(function* () { + const serverOwner = yield* OpenCodeServerOwner.make({ + binaryPath: settings.binaryPath, + directory: cwd, + ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), + ...(environment ? { environment } : {}), + }); + const probe = probeOpenCodeRuntime(settings, environment).pipe( + Effect.provideService(HttpClient.HttpClient, server), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), + ); + return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ); + }), + ); +}); + +it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { + it.effect("shows a codex-style missing binary message", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, false); + NodeAssert.equal( + snapshot.message, + "OpenCode CLI (`opencode`) is not installed or not on PATH.", + ); + }), + ); + + it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); + }), + ); + + it.effect("times out a hanging local CLI version probe", () => + Effect.gen(function* () { + runtimeMock.state.runVersionPending = true; + const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("4 seconds"); + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + variants: { + none: {}, + low: {}, + medium: {}, + high: {}, + xhigh: {}, + }, + }, + }, + }, + ], + default: {}, + }, + agents: [ + { name: "build", hidden: false, mode: "primary" }, + { name: "plan", hidden: false, mode: "primary" }, + ], + }; + + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); + + NodeAssert.ok(model); + const variantDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "variant" && descriptor.type === "select", + ); + NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); + NodeAssert.equal( + variantDescriptor.options.find((option) => option.isDefault === true)?.id, + "medium", + ); + const agentDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "agent" && descriptor.type === "select", + ); + NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); + NodeAssert.equal( + agentDescriptor.options.find((option) => option.isDefault === true)?.id, + "build", + ); + }), + ); + + it.effect("includes OpenCode skills in the provider snapshot", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + variants: {}, + }, + }, + }, + ], + default: {}, + }, + agents: [], + skills: [ + { + name: "openclaw-review", + description: "Review OpenClaw workflow changes.", + location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", + }, + { + name: "openclaw-triage", + description: "Triage OpenClaw routing issues.", + location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", + }, + { + name: "missing-location", + description: "This incomplete SDK row should be skipped.", + location: "", + }, + ], + }; + + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + + NodeAssert.deepEqual( + snapshot.skills.map((skill) => ({ + name: skill.name, + path: skill.path, + enabled: skill.enabled, + shortDescription: skill.shortDescription, + })), + [ + { + name: "openclaw-review", + path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", + enabled: true, + shortDescription: "Review OpenClaw workflow changes.", + }, + { + name: "openclaw-triage", + path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", + enabled: true, + shortDescription: "Triage OpenClaw routing issues.", + }, + ], + ); + }), + ); + + it.effect("loads local inventory from a scoped OpenCode server", () => + Effect.gen(function* () { + yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" })); + + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:4301", + directory: process.cwd(), + serverPassword: "secret-password", + }, + ]); + NodeAssert.equal(runtimeMock.state.closeCalls, 1); + NodeAssert.equal(runtimeMock.state.inventoryCwd, null); + }), + ); + + it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode v2.0.18\n"; + const snapshot = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.succeed([ + { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] }, + { + providerID: "opencode", + id: "space-bunny-free", + name: "Space Bunny Free", + variants: [{ id: "low" }, { id: "medium" }, { id: "high" }], + }, + ]), + ); + + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.equal(snapshot.version, "2.0.18"); + NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access", + ]); + // Plan mode runs as OpenCode's plan agent, and every session can compact. + NodeAssert.equal(snapshot.showInteractionModeToggle, true); + NodeAssert.deepEqual( + snapshot.slashCommands.map((command) => command.name), + ["compact"], + ); + NodeAssert.deepEqual( + snapshot.models.map((model) => model.slug), + ["opencode/big-pickle", "opencode/space-bunny-free"], + ); + const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free") + ?.capabilities?.optionDescriptors?.[0]; + NodeAssert.deepEqual( + variant?.type === "select" ? variant.options.map((option) => option.id) : [], + ["low", "medium", "high"], + ); + NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); + }), + ); + + it.effect("reports a failed OpenCode 2 model list without the server's response", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode v2.0.18\n"; + const snapshot = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + detail: 'status=500 body={"token":"leaked-response-body"}', + }), + ), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.message, "OpenCode could not load its model list."); + // A failed catalog read still leaves the session able to compact. + NodeAssert.deepEqual( + snapshot.slashCommands.map((command) => command.name), + ["compact"], + ); + }), + ); + + it.effect("uses an environment-only password for local inventory", () => + Effect.gen(function* () { + yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + OPENCODE_SERVER_PASSWORD: "environment-password", + }); + + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:4301", + directory: process.cwd(), + serverPassword: "environment-password", + }, + ]); + }), + ); + + it.effect("uses the settings password when local environment auth differs", () => + Effect.gen(function* () { + yield* checkProvider( + makeOpenCodeSettings({ serverPassword: "settings-password" }), + process.cwd(), + { OPENCODE_SERVER_PASSWORD: "environment-password" }, + ); + + NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password"); + }), + ); + + it.effect("reports local model inventory failures without treating them as empty", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error("opencode models failed"); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.models.length, 0); + NodeAssert.equal( + snapshot.message, + "Failed to load OpenCode provider inventory: opencode models failed", + ); + }), + ); +}); + +it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => { + it.effect("does not send a local environment password to a configured server", () => + Effect.gen(function* () { + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), + process.cwd(), + { OPENCODE_SERVER_PASSWORD: "local-secret" }, + ); + + NodeAssert.equal(snapshot.version, "1.14.19"); + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:9999", + directory: process.cwd(), + }, + ]); + }), + ); + + it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () => + Effect.gen(function* () { + const settings = makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }); + const snapshot = yield* checkProvider( + settings, + process.cwd(), + undefined, + replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), + ); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.version, "2.0.18"); + NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access", + ]); + NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); + }), + ); + + it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => + Effect.gen(function* () { + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }), + process.cwd(), + undefined, + replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal( + snapshot.message, + "OpenCode server rejected authentication. Check the server URL and password.", + ); + }), + ); + + it.effect("rejects an unsupported server before loading inventory", () => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error( + "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", + ); + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.models.length, 0); + NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/); + NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); + }), + ); + + it.effect("surfaces a friendly auth error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error("401 Unauthorized"); + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( + snapshot.message, + "OpenCode server rejected authentication. Check the server URL and password.", + ); + }), + ); + + it.effect("surfaces a friendly connection error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error( + "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", + ); + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( + 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.", + ); + }), + ); +}); + +const bigPickle: OpenCode2Model = { + providerID: "opencode", + id: "big-pickle", + name: "Big Pickle", + variants: [], +}; + +it.effect("waits for a fresh OpenCode 2 server to list its models", () => + Effect.gen(function* () { + // A fresh server lists nothing until its catalog loads. + const replies: Array> = [[], [], [bigPickle]]; + const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle])); + const fiber = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () => + Effect.gen(function* () { + let listed: ReadonlyArray = [bigPickle]; + const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed)); + NodeAssert.deepEqual(yield* load, [bigPickle]); + listed = []; + const fiber = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("6 seconds"); + NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); + }).pipe(Effect.provide(TestClock.layer())), +); + +// What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29). +const builtinCommands = [ + { name: "init", description: "guided AGENTS.md setup" }, + { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" }, +]; +const builtinSkills = [ + { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" }, + { id: "report", name: "Report", path: "/builtin/report.md" }, +]; +const scanned: OpenCode2Workspace = { + commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }], + skills: [ + ...builtinSkills, + { + id: "greet", + name: "greet", + path: "/work/.opencode/skills/greet/SKILL.md", + description: "Greets the user with the secret word MANGO.", + }, + ], +}; + +it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () => + Effect.gen(function* () { + // A directory the server has not served yet lists no commands until its scan ends. + const reads: Array = [{ commands: [], skills: [] }, scanned]; + const scanEnded = yield* Deferred.make(); + const load = yield* loadOpenCode2Workspace( + Effect.sync(() => reads.shift()!), + Deferred.await(scanEnded), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(reads.length, 1); + yield* Deferred.succeed(scanEnded, undefined); + NodeAssert.deepEqual(yield* Fiber.join(load), scanned); + }), +); + +it.effect("takes a served OpenCode 2 directory's first listing as is", () => + Effect.gen(function* () { + const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never); + NodeAssert.deepEqual(workspace, scanned); + }), +); + +it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => { + NodeAssert.deepEqual( + openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [ + skill.name, + skill.displayName, + skill.path, + ]), + [ + ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"], + ["opencode", "OpenCode", "/builtin/opencode.md"], + ["report", "Report", "/builtin/report.md"], + ], + ); + NodeAssert.deepEqual( + openCode2CommandsToServerProviderSlashCommands([ + ...scanned.commands, + { name: "compact", description: "a workspace command named like T3's own" }, + ]).map((command) => command.name), + ["compact", "init", "review", "hello"], + ); +}); diff --git a/apps/server/src/provider/OpenCodeProvider.ts b/apps/server/src/provider/OpenCodeProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff4580e00e2da24d9c941acef3b8e3d12680bc71 --- /dev/null +++ b/apps/server/src/provider/OpenCodeProvider.ts @@ -0,0 +1,747 @@ +import { + type ModelCapabilities, + type OpenCodeSettings, + type ServerProviderModel, + type ServerProviderSkill, + type ServerProviderSlashCommand, +} 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 Schedule from "effect/Schedule"; + +import { createModelCapabilities } from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import { + buildServerProvider, + COMPACT_SLASH_COMMAND, + nonEmptyTrimmed, + providerModelsFromSettings, + type ServerProviderDraft, +} from "./providerSnapshot.ts"; +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import type { ProbedOpenCode } from "./opencodeVersionProbe.ts"; +import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; + +const OPENCODE_PRESENTATION = { + displayName: "OpenCode", + showInteractionModeToggle: false, +} as const; + +class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ + readonly cause?: unknown; + readonly detail: string; +}> {} + +function normalizeProbeMessage(message: string): string | undefined { + const trimmed = message.trim(); + if (trimmed.length === 0) { + return undefined; + } + if ( + trimmed === "An error occurred in Effect.tryPromise" || + trimmed === "An error occurred in Effect.try" + ) { + return undefined; + } + return trimmed; +} + +function normalizedErrorMessage(cause: unknown): string | undefined { + if (cause instanceof OpenCodeProbeError) { + return normalizeProbeMessage(cause.detail); + } + + if (!(cause instanceof Error)) { + return undefined; + } + + return normalizeProbeMessage(cause.message); +} + +function formatOpenCodeProbeError(input: { + readonly cause: unknown; + readonly isExternalServer: boolean; + readonly phase: "version" | "inventory"; + readonly serverUrl: string; +}): { readonly installed: boolean; readonly message: string } { + const detail = normalizedErrorMessage(input.cause); + const lower = detail?.toLowerCase() ?? ""; + + if (input.isExternalServer) { + if ( + lower.includes("401") || + lower.includes("403") || + lower.includes("unauthorized") || + lower.includes("forbidden") + ) { + return { + installed: true, + message: "OpenCode server rejected authentication. Check the server URL and password.", + }; + } + + if ( + lower.includes("econnrefused") || + lower.includes("enotfound") || + lower.includes("fetch failed") || + lower.includes("networkerror") || + lower.includes("timed out") || + lower.includes("timeout") || + lower.includes("socket hang up") + ) { + 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.`, + }; + } + + return { + installed: true, + message: detail ?? "Failed to connect to the configured OpenCode server.", + }; + } + + if (lower.includes("enoent") || lower.includes("notfound")) { + return { + installed: false, + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + }; + } + + if (lower.includes("quarantine")) { + return { + installed: true, + message: + "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", + }; + } + + if (lower.includes("invalid code signature") || lower.includes("corrupted")) { + return { + installed: true, + message: + "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", + }; + } + + const failureLabel = + input.phase === "inventory" + ? "Failed to load OpenCode provider inventory" + : "Failed to execute OpenCode CLI health check"; + return { + installed: true, + message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, + }; +} + +function titleCaseSlug(value: string): string { + const segments: Array = []; + for (const segment of value.split(/[-_/]+/)) { + if (segment.length > 0) { + segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); + } + } + return segments.join(" "); +} + +function inferDefaultVariant( + providerID: string, + variants: ReadonlyArray, +): string | undefined { + if (variants.length === 1) { + return variants[0]; + } + if (providerID === "anthropic" || providerID.startsWith("google")) { + return variants.includes("high") ? "high" : undefined; + } + if (providerID === "openai" || providerID === "opencode") { + return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; + } + return undefined; +} + +function inferDefaultAgent(agents: ReadonlyArray): string | undefined { + return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; +} + +const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + ], + currentValue: "medium", + }, + { + id: "agent", + label: "Agent", + type: "select", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + currentValue: "build", + }, + ], +}); + +function openCodeCapabilitiesForModel(input: { + readonly providerID: string; + readonly model: ProviderListResponse["all"][number]["models"][string]; + readonly agents: ReadonlyArray; +}): ModelCapabilities { + const rawVariantValues = Object.keys(input.model.variants ?? {}); + // When a model advertises no variants, synthesize the standard reasoning + // levels so the composer still offers a Reasoning selector (mirrors the + // Codex/Grok experience where reasoning is always configurable). The set + // covers the common OpenCode variant spectrum; `inferDefaultVariant` + // picks the provider-appropriate default (e.g. medium for openai/opencode). + const variantValues = + rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; + const defaultVariant = inferDefaultVariant(input.providerID, variantValues); + const variantOptions = variantValues.map((value) => + defaultVariant === value + ? { id: value, label: titleCaseSlug(value), isDefault: true as const } + : { id: value, label: titleCaseSlug(value) }, + ); + const primaryAgents = input.agents.filter( + (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), + ); + const defaultAgent = inferDefaultAgent(primaryAgents); + const agentOptions = primaryAgents.map((agent) => + defaultAgent === agent.name + ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } + : { id: agent.name, label: titleCaseSlug(agent.name) }, + ); + return createModelCapabilities({ + optionDescriptors: [ + ...(variantOptions.length > 0 + ? [ + { + id: "variant", + label: "Reasoning", + type: "select" as const, + options: variantOptions, + ...(defaultVariant ? { currentValue: defaultVariant } : {}), + }, + ] + : []), + ...(agentOptions.length > 0 + ? [ + { + id: "agent", + label: "Agent", + type: "select" as const, + options: agentOptions, + ...(defaultAgent ? { currentValue: defaultAgent } : {}), + }, + ] + : []), + ], + }); +} + +function flattenOpenCodeModels( + input: OpenCodeRuntime.OpenCodeInventory, +): ReadonlyArray { + const connected = new Set(input.providerList.connected); + const models: Array = []; + + for (const provider of input.providerList.all) { + if (!connected.has(provider.id)) { + continue; + } + + for (const model of Object.values(provider.models)) { + const name = nonEmptyTrimmed(model.name); + if (!name) { + continue; + } + + const subProvider = nonEmptyTrimmed(provider.name); + models.push({ + slug: `${provider.id}/${model.id}`, + name, + ...(subProvider ? { subProvider } : {}), + isCustom: false, + capabilities: openCodeCapabilitiesForModel({ + providerID: provider.id, + model, + agents: input.agents, + }), + }); + } + } + + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +function trimOptional(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +export function openCodeSkillsToServerProviderSkills( + input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined, +): ReadonlyArray { + const skills: ServerProviderSkill[] = []; + for (const skill of input ?? []) { + const name = trimOptional(skill.name); + const path = trimOptional(skill.location); + if (!name || !path) { + continue; + } + + const description = trimOptional(skill.description); + skills.push({ + name, + path, + enabled: true, + ...(description ? { description, shortDescription: description } : {}), + }); + } + + return skills.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +export function openCodeCommandsToServerProviderSlashCommands( + input: OpenCodeRuntime.OpenCodeInventory["commands"], +): ReadonlyArray { + const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; + const names = new Set([COMPACT_SLASH_COMMAND.name]); + for (const command of input ?? []) { + const name = trimOptional(command.name); + if (!name || names.has(name) || command.source === "skill") continue; + names.add(name); + const description = trimOptional(command.description); + const hint = trimOptional(command.hints.join(" ")); + commands.push({ + name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + }); + } + return commands; +} + +export const makePendingOpenCodeProvider = ( + openCodeSettings: OpenCodeSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = providerModelsFromSettings( + [], + openCodeSettings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + + if (!openCodeSettings.enabled) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: + openCodeSettings.serverUrl.trim().length > 0 + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode provider status has not been checked in this session yet.", + }, + }); + }); + +/** One model an OpenCode 2 server lists, as the status check reads it. */ +export interface OpenCode2Model { + readonly providerID: string; + readonly id: string; + readonly name: string; + readonly variants: ReadonlyArray<{ readonly id: string }>; +} + +/** + * A server loads its catalog lazily and lists nothing for its first few + * hundred milliseconds, and a local instance starts a fresh server for each + * status check. So an empty list is read again for a while, and the last + * non-empty list stands in if the catalog still has not loaded. + */ +export const makeOpenCode2ModelLoader = ( + list: Effect.Effect, E>, +) => + Effect.sync(() => { + let lastLoaded: ReadonlyArray = []; + return list.pipe( + Effect.repeat({ + until: (models) => models.length > 0, + schedule: Schedule.spaced("250 millis"), + }), + Effect.timeoutOption("5 seconds"), + Effect.map((loaded) => { + if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value; + return lastLoaded; + }), + ); + }); + +/** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ +export interface OpenCode2Workspace { + readonly skills: ReadonlyArray<{ + readonly id: string; + readonly name: string; + readonly path: string; + readonly description?: string | undefined; + }>; + readonly commands: ReadonlyArray<{ + readonly name: string; + readonly description?: string | undefined; + }>; +} + +/** + * A server scans a directory it has not served yet in the background: its + * first read lists no commands at all (not even the built-ins), and + * `command.updated` plus `skill.updated` for the directory end the scan. A + * directory it already serves lists everything at once. `scanned` must be + * listening before `read` runs, since the read is what starts the scan. + */ +export const loadOpenCode2Workspace = ( + read: Effect.Effect, + scanned: Effect.Effect, +) => + Effect.gen(function* () { + const first = yield* read; + if (first.commands.length > 0) return first; + yield* scanned.pipe(Effect.timeoutOption("10 seconds")); + return yield* read; + }); + +export function openCode2SkillsToServerProviderSkills( + skills: OpenCode2Workspace["skills"], +): ReadonlyArray { + return skills + .map((skill) => { + const description = trimOptional(skill.description); + return { + name: skill.id, + path: skill.path, + enabled: true, + ...(skill.name === skill.id ? {} : { displayName: skill.name }), + ...(description ? { description, shortDescription: description } : {}), + }; + }) + .toSorted((left, right) => left.name.localeCompare(right.name)); +} + +export function openCode2CommandsToServerProviderSlashCommands( + commands: OpenCode2Workspace["commands"], +): ReadonlyArray { + const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; + const names = new Set([COMPACT_SLASH_COMMAND.name]); + for (const command of commands) { + const name = trimOptional(command.name); + if (!name || names.has(name)) continue; + names.add(name); + const description = trimOptional(command.description); + slashCommands.push({ name, ...(description ? { description } : {}) }); + } + return slashCommands; +} + +/** + * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: + * OpenCode has no reviewer that approves routine actions. Plan mode is its + * `plan` agent, so the composer's mode toggle drives it. + */ +const OPENCODE_2_PRESENTATION = { + ...OPENCODE_PRESENTATION, + showInteractionModeToggle: true, + supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], +} as const; + +function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { + const variants = model.variants.map((variant) => variant.id); + const defaultVariant = inferDefaultVariant(model.providerID, variants); + return createModelCapabilities({ + optionDescriptors: + variants.length === 0 + ? [] + : [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: variants.map((id) => ({ + id, + label: titleCaseSlug(id), + ...(id === defaultVariant ? { isDefault: true } : {}), + })), + ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), + }, + ], + }); +} + +const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( + settings: OpenCodeSettings, + version: string, + checkedAt: string, + loadModels: Effect.Effect, OpenCodeRuntime.OpenCodeRuntimeError>, +) { + const result = yield* Effect.exit(loadModels); + const probe = (status: "ready" | "warning" | "error", message: string) => ({ + installed: true, + version, + status, + auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) }, + message, + }); + if (result._tag === "Failure") { + // The detail can carry a response body; it stays in the log, not the status. + yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); + return buildServerProvider({ + presentation: OPENCODE_2_PRESENTATION, + enabled: true, + checkedAt, + models: providerModelsFromSettings( + [], + settings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + // Compaction does not depend on the model catalog. + slashCommands: [COMPACT_SLASH_COMMAND], + probe: probe("error", "OpenCode could not load its model list."), + }); + } + const models = providerModelsFromSettings( + result.value + .map((model) => ({ + slug: `${model.providerID}/${model.id}`, + name: model.name, + isCustom: false, + capabilities: openCode2ModelCapabilities(model), + })) + .toSorted((left, right) => left.name.localeCompare(right.name)), + settings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + return buildServerProvider({ + presentation: OPENCODE_2_PRESENTATION, + enabled: true, + checkedAt, + models, + // Every session can compact; a workspace snapshot adds that directory's commands. + slashCommands: [COMPACT_SLASH_COMMAND], + probe: + result.value.length > 0 + ? probe( + "ready", + `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`, + ) + : probe("warning", "OpenCode 2 is running, but it did not list any models yet."), + }); +}); + +/** + * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary, + * the version endpoints for a configured server. The status check refreshes it, so an in-place + * upgrade re-routes the instance. + */ +export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( + openCodeSettings: OpenCodeSettings, + cwd: string, + probeRuntime: Effect.Effect, + loadOpenCode2Models: Effect.Effect< + ReadonlyArray, + OpenCodeRuntime.OpenCodeRuntimeError + >, +): Effect.fn.Return< + ServerProviderDraft, + never, + OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner +> { + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; + const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; + 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, + phase: "version" | "inventory" = "version", + ) => { + const failure = formatOpenCodeProbeError({ + cause, + isExternalServer, + phase, + serverUrl: openCodeSettings.serverUrl, + }); + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: openCodeSettings.enabled, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: failure.installed, + version, + status: "error", + auth: { status: "unknown" }, + message: failure.message, + }, + }); + }; + + if (!openCodeSettings.enabled) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: false, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isExternalServer + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + const probedExit = yield* Effect.exit( + probeRuntime.pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ + cause, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + }), + ), + ), + ); + if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); + const probed = probedExit.value; + if (probed.generation === "v2") { + return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); + } + let version: string | null = probed.version; + if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: openCodeSettings.enabled, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: true, + version: probed.version, + status: "error", + auth: { status: "unknown" }, + message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, + }, + }); + } + + const loadInventory = (server: { + readonly url: string; + readonly serverPassword?: string; + readonly version: string; + }) => + openCodeRuntime + .loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), + }), + ) + .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); + const inventoryEffect = isExternalServer + ? openCodeRuntime + .connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + directory: cwd, + serverUrl: openCodeSettings.serverUrl, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }) + .pipe(Effect.flatMap(loadInventory), Effect.scoped) + : serverOwner.withServer(loadInventory); + const inventoryExit = yield* Effect.exit( + inventoryEffect.pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ + cause, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + }), + ), + ), + ); + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); + } + + version = inventoryExit.value.version; + + const models = providerModelsFromSettings( + flattenOpenCodeModels(inventoryExit.value.inventory), + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); + const connectedCount = inventoryExit.value.inventory.providerList.connected.length; + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + skills, + slashCommands: openCodeCommandsToServerProviderSlashCommands( + inventoryExit.value.inventory.commands, + ), + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", + }, + }); +}); diff --git a/apps/server/src/provider/PiProvider.test.ts b/apps/server/src/provider/PiProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ca54dbf3cb0a279003558792f8d6e570ababc4d --- /dev/null +++ b/apps/server/src/provider/PiProvider.test.ts @@ -0,0 +1,80 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./PiProvider.ts"; + +const encoder = new TextEncoder(); + +function processHandle(input: { + readonly stdout?: string; + readonly stderr?: string; + readonly exitCode?: number; +}) { + const bytes = (value: string | undefined) => + value === undefined || value.length === 0 + ? Stream.empty + : Stream.succeed(encoder.encode(value)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(900_000_001), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: bytes(input.stdout), + stderr: bytes(input.stderr), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function piProbeSpawner(version: string) { + return ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + return Effect.succeed( + args.includes("--version") + ? processHandle({ stdout: `pi ${version}\n` }) + : processHandle({ stderr: "RPC startup failed", exitCode: 1 }), + ); + }); +} + +const settings = { + enabled: true, + binaryPath: "pi", + launchArgs: "", + customModels: [], +} as const; + +describe("PiProvider", () => { + it.effect("requires the first published Pi version with entries and settlement hooks", () => + Effect.gen(function* () { + const snapshot = yield* checkPiProviderStatus(settings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.80.3")), + ); + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.version, "0.80.3"); + assert.include(snapshot.message ?? "", `Pi ${MINIMUM_PI_VERSION} or newer`); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps compatible Pi selectable when optional discovery fails", () => + Effect.gen(function* () { + const snapshot = yield* checkPiProviderStatus(settings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.84.3")), + ); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "unknown"); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["default"], + ); + assert.include(snapshot.message ?? "", "could not refresh its models and commands"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/PiProvider.ts b/apps/server/src/provider/PiProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b4a9af7bff88b431c030f1cfb39bd70cadf20f6 --- /dev/null +++ b/apps/server/src/provider/PiProvider.ts @@ -0,0 +1,441 @@ +/** + * PiProvider — snapshot/probe layer for the Pi coding agent. + * + * Health is probed with `pi --version`. Models, the user's default model, and + * the user's commands (extension slash commands, prompt templates, skills) + * are discovered through a short-lived ephemeral RPC session + * (`pi --mode rpc --no-session`), so everything the user configured in + * `~/.pi/agent` — custom providers, models.json entries, extensions, skills — + * shows up in T3 without any hardcoded catalog. + */ +import { + type CustomModelSetting, + type PiSettings, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import { + buildPiRpcLaunch, + resolvePiLaunchArgs, +} from "../orchestration-v2/Adapters/piT3McpInjection.ts"; +import { + makePiRpcConnection, + piRecordField as recordField, + piRecordString as recordString, +} from "../orchestration-v2/Adapters/PiRpc.ts"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "./providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "./providerMaintenance.ts"; +import { + EMPTY_PI_MODEL_CAPABILITIES, + thinkingCapabilitiesForPiModel, +} from "./piThinkingCapabilities.ts"; +import { + parsePiDiscoveredCommands, + withPiBuiltinSlashCommands, + type PiDiscoveredCommands, +} from "./PiCommands.ts"; + +const PI_PRESENTATION = { + displayName: "Pi", + showInteractionModeToggle: false, + supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], + // The adapter reports context usage from Pi's streaming usage while a + // turn runs, so clients can reserve the meter before the first settle. + reportsContextWindow: true, + requiresNewThreadForModelChange: false, +} as const; + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000; +/** + * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. + * Version 0.80.5 was the first published package containing both hooks. T3 + * needs them for rollback boundaries and reliable turn terminalization. + */ +export const MINIMUM_PI_VERSION = "0.80.5"; + +/** Deferring to the user's own settings.json default model. */ +const PI_DEFAULT_MODEL: ServerProviderModel = { + slug: "default", + name: "Pi default", + isCustom: false, + capabilities: EMPTY_PI_MODEL_CAPABILITIES, +}; + +interface PiDiscovery extends PiDiscoveredCommands { + readonly models: ReadonlyArray; + readonly authenticated: boolean; +} + +function piModelsFromSettings( + customModels: ReadonlyArray | undefined, + discovered: ReadonlyArray = [], +): ReadonlyArray { + return providerModelsFromSettings( + [PI_DEFAULT_MODEL, ...discovered], + customModels ?? [], + EMPTY_PI_MODEL_CAPABILITIES, + ); +} + +function parseDiscoveredModels( + data: unknown, + defaultThinkingLevel: unknown, +): ReadonlyArray { + const models = recordField(data, "models"); + if (!Array.isArray(models)) return []; + const seen = new Set(); + const parsed: Array = []; + for (const model of models) { + const provider = recordString(model, "provider"); + const id = recordString(model, "id"); + if (provider === undefined || id === undefined) continue; + const slug = `${provider}/${id}`; + if (seen.has(slug)) continue; + seen.add(slug); + parsed.push({ + slug, + name: recordString(model, "name") ?? slug, + isCustom: false, + capabilities: thinkingCapabilitiesForPiModel(model, defaultThinkingLevel), + }); + } + return parsed; +} + +const discoverPiViaRpc = ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv, + launchArgs: ReadonlyArray, + cwd?: string, +) => + Effect.gen(function* () { + const launch = buildPiRpcLaunch({ + launchArgs, + environment, + mcpSession: undefined, + extensionPath: undefined, + ephemeral: true, + }); + const connection = yield* makePiRpcConnection({ + command: piSettings.binaryPath || "pi", + args: launch.args, + cwd, + env: launch.env, + }); + yield* Stream.fromQueue(connection.events).pipe( + Stream.runDrain, + Effect.ignore, + Effect.forkScoped, + ); + const stateData = yield* connection.request({ type: "get_state" }); + const modelsData = yield* connection.request({ type: "get_available_models" }); + const commandsData = yield* connection + .request({ type: "get_commands" }) + .pipe(Effect.orElseSucceed(() => undefined)); + const discoveredModels = parseDiscoveredModels( + modelsData, + recordString(stateData, "thinkingLevel"), + ); + const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); + return { + models: discoveredModels, + slashCommands: withPiBuiltinSlashCommands(slashCommands), + skills, + authenticated: discoveredModels.length > 0, + } satisfies PiDiscovery; + }).pipe(Effect.scoped); + +const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const command = piSettings.binaryPath || "pi"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export function buildInitialPiProviderSnapshot( + piSettings: PiSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = piModelsFromSettings(piSettings.customModels); + if (!piSettings.enabled) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Pi is disabled in T3 Code settings.", + }, + }); + } + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Pi CLI availability...", + }, + }); + }); +} + +export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = piModelsFromSettings(piSettings.customModels); + + if (!piSettings.enabled) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Pi is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag }); + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g @earendil-works/pi-coding-agent`." + : "Failed to execute Pi CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Pi CLI is installed but timed out while running `pi --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Pi CLI is installed but failed to run.", + }, + }); + } + + if (version === null) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: `T3 Code could not determine the Pi version. Pi ${MINIMUM_PI_VERSION} or newer is required.`, + }, + }); + } + + if (compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Pi ${version} is unsupported. Update to Pi ${MINIMUM_PI_VERSION} or newer.`, + }, + }); + } + + const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); + if (!resolvedLaunchArgs.ok) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: resolvedLaunchArgs.message, + }, + }); + } + + const discoveryExit = yield* discoverPiViaRpc( + piSettings, + environment, + resolvedLaunchArgs.args, + cwd, + ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Pi RPC discovery failed.", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + message: + "Pi is available, but T3 Code could not refresh its models and commands. The live session will retry startup.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + message: + "Pi is available, but model and command discovery needs interactive input. The live session will handle it.", + }, + }); + } + + const discovery = discoveryExit.value.value; + const models = piModelsFromSettings(piSettings.customModels, discovery.models); + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models, + slashCommands: discovery.slashCommands, + skills: discovery.skills, + probe: { + installed: true, + version, + status: discovery.authenticated ? "ready" : "warning", + auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, + ...(discovery.authenticated + ? {} + : { + message: + "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.", + }), + }, + }); +}); + +export const enrichPiSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Pi version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/ProviderAuthFlow.ts b/apps/server/src/provider/ProviderAuthFlow.ts index f69de4b6bc3700637aa184d53cf67dab590bd32c..ff7c5cfd14b3d06a9f7b89fb04c4cea32db90f8e 100644 --- a/apps/server/src/provider/ProviderAuthFlow.ts +++ b/apps/server/src/provider/ProviderAuthFlow.ts @@ -21,7 +21,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import type * as ProviderAuthService from "./Services/ProviderAuthService.ts"; +import type * as ProviderAuthService from "./ProviderAuthService.ts"; export interface ProviderAuthFlowContext { readonly flowId: string; diff --git a/apps/server/src/provider/ProviderAuthService.test.ts b/apps/server/src/provider/ProviderAuthService.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..46d27902b461de8b5f96dc29e12041e56ff30924 --- /dev/null +++ b/apps/server/src/provider/ProviderAuthService.test.ts @@ -0,0 +1,979 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ProviderSetupError, + ProviderThreadId, + ThreadId, + type OrchestrationV2ProviderSession, + type OrchestrationV2ThreadShell, + type ProviderAuthState, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; +import { AcpProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/AcpAdapterV2.ts"; +import type { ProviderInstance } from "./ProviderDriver.ts"; +import * as ProviderAuthService from "./ProviderAuthService.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; + +const instanceId = ProviderInstanceId.make("antigravity-personal"); +const otherInstanceId = ProviderInstanceId.make("antigravity-work"); +const unsupportedInstanceId = ProviderInstanceId.make("codex"); +const driverKind = ProviderDriverKind.make("antigravity"); +const owner = "paired-client-owner"; +const otherOwner = "paired-client-other"; +const flowId = "test-sign-in-flow"; +const callbackUrl = "http://127.0.0.1:48123/?state=test-state&code=test-code"; +const now = "2026-09-02T00:00:00.000Z"; +const idleAuthState: ProviderAuthState = { + instanceId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, +}; +const waitingAuthState: ProviderAuthState = { + ...idleAuthState, + phase: "waiting", + flowId, + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=test-state", + expiresAt: "2026-09-02T00:05:00.000Z", +}; + +function makeInstance(input: { + instanceId: ProviderInstanceId; + enabled: boolean; + auth?: ProviderAuthService.ProviderAuthController; +}): ProviderInstance { + return { + ...input, + driverKind, + displayName: undefined, + continuationIdentity: { driverKind, continuationKey: input.instanceId }, + get snapshot(): never { + throw new Error("Auth routing must not refresh the provider snapshot."); + }, + get orchestrationAdapter(): never { + throw new Error("Auth routing must not start an adapter session."); + }, + get textGeneration(): never { + throw new Error("Auth routing must not generate text."); + }, + }; +} + +const nowUtc = DateTime.makeUnsafe(now); + +/** + * A thread bound to a provider instance with (or without) a live native + * thread. The auth service reads only the routing fields, so the rest of the + * shell row is left out. + */ +function makeThread( + thread: string, + input: { readonly providerInstanceId?: ProviderInstanceId; readonly active?: boolean } = {}, +): OrchestrationV2ThreadShell { + const shell: Pick< + OrchestrationV2ThreadShell, + "id" | "providerInstanceId" | "activeProviderThreadId" + > = { + id: ThreadId.make(thread), + providerInstanceId: input.providerInstanceId ?? instanceId, + activeProviderThreadId: + input.active === false ? null : ProviderThreadId.make(`${thread}:native`), + }; + return shell as unknown as OrchestrationV2ThreadShell; +} + +function makeSession( + session: string, + status: OrchestrationV2ProviderSession["status"] = "ready", + providerInstanceId = instanceId, +): OrchestrationV2ProviderSession { + return { + id: ProviderSessionId.make(session), + driver: driverKind, + providerInstanceId, + status, + cwd: "/workspace", + model: null, + capabilities: AcpProviderCapabilitiesV2, + createdAt: nowUtc, + updatedAt: nowUtc, + lastError: null, + }; +} + +function sessionThreads(list: ReadonlyArray) { + return { + threads: list.map((session) => makeThread(session.id)), + sessions: new Map(list.map((session) => [ThreadId.make(session.id), [session]])), + }; +} + +const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* ( + input: { + enabled?: boolean; + threads?: ReadonlyArray; + sessions?: ReadonlyMap>; + shellError?: boolean; + stopError?: boolean; + logoutError?: ProviderSetupError; + sharedCredentials?: boolean; + sharedBusy?: boolean; + sharedBusyEffect?: Effect.Effect; + responds?: boolean; + beforeLogout?: Effect.Effect; + beforeStop?: Effect.Effect; + beforeListSessions?: Effect.Effect; + onLookup?: Effect.Effect; + onListThreads?: (instances: ProviderInstance[]) => void; + } = {}, +) { + const actions: string[] = []; + const registryChanges = yield* PubSub.unbounded(); + const threads = input.threads ?? []; + const sessions = new Map( + [...(input.sessions?.entries() ?? [])].map(([threadId, list]) => [threadId, [...list]]), + ); + const released: string[] = []; + const idle = idleAuthState; + let state = idle; + let flowOwner: string | undefined; + let gateClosed = false; + + const checkOwner = Effect.fn("ProviderAuthService.test.checkOwner")(function* ( + ownerSessionId: string, + requestedFlowId: string, + operation: string, + ) { + if (ownerSessionId !== flowOwner || requestedFlowId !== state.flowId) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: "This sign-in belongs to another client or has expired.", + }); + } + }); + + const auth: ProviderAuthService.ProviderAuthController = { + ...(input.sharedCredentials + ? { credentialBinding: { owner: "provider" as const, key: "shared" } } + : {}), + ...(input.responds + ? { + respond: Effect.fn(function* (ownerSessionId, request) { + yield* checkOwner(ownerSessionId, request.flowId, "respond"); + actions.push(`respond:${request.response.type}`); + return state; + }), + } + : {}), + start: Effect.fn(function* (ownerSessionId, stopSessions) { + gateClosed = true; + actions.push("close-gate"); + yield* stopSessions ?? Effect.void; + flowOwner = ownerSessionId; + state = waitingAuthState; + actions.push("start-sign-in"); + return state; + }), + complete: Effect.fn(function* (ownerSessionId, request) { + yield* checkOwner(ownerSessionId, request.flowId, "complete"); + if (request.callbackUrl !== callbackUrl) { + return yield* new ProviderSetupError({ + instanceId, + operation: "complete", + detail: "The redirect URL does not match this sign-in.", + }); + } + state = { ...idle, flowId, phase: "succeeded" }; + return state; + }), + cancel: Effect.fn(function* (ownerSessionId, requestedFlowId) { + yield* checkOwner(ownerSessionId, requestedFlowId, "cancel"); + state = { ...idle, flowId, phase: "cancelled" }; + return state; + }), + logout: Effect.fn(function* (stopSessions) { + gateClosed = true; + actions.push("close-gate"); + yield* stopSessions; + if (input.logoutError) return yield* input.logoutError; + yield* input.beforeLogout ?? Effect.void; + actions.push("native-logout"); + state = idle; + flowOwner = undefined; + return state; + }), + subscribe: (ownerSessionId) => + Stream.fromEffect(Effect.sync(() => (ownerSessionId === flowOwner ? state : idle))), + isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout", + }; + const instances = [ + makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), + makeInstance({ instanceId: unsupportedInstanceId, enabled: true }), + ...(input.sharedCredentials + ? [ + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...auth, + isChangingCredentials: + input.sharedBusyEffect ?? Effect.succeed(input.sharedBusy ?? false), + invalidate: Effect.sync(() => { + actions.push("invalidate-shared"); + }), + }, + }), + ] + : []), + ]; + const service = yield* ProviderAuthService.makeProviderAuthService.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.gen(function* () { + const found = instances.find((instance) => instance.instanceId === id); + yield* input.onLookup ?? Effect.void; + return found; + }), + listInstances: Effect.succeed(instances), + subscribeChanges: PubSub.subscribe(registryChanges), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => + Effect.gen(function* () { + assert.isTrue(gateClosed); + actions.push("list-threads"); + input.onListThreads?.(instances); + yield* input.beforeListSessions ?? Effect.void; + return yield* input.shellError + ? Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId: ThreadId.make("shell"), + cause: new Error("private database diagnostics"), + }), + ) + : Effect.succeed(threads.map((thread) => thread.id)); + }), + getThreadRecords: (threadId) => + Effect.sync(() => { + actions.push(`sessions:${threadId}`); + return { + providerSessions: sessions.get(threadId) ?? [], + } as never; + }), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + release: ({ providerSessionId, reason }) => + Effect.gen(function* () { + assert.isTrue(gateClosed); + actions.push(`stop:${providerSessionId}`); + yield* input.beforeStop ?? Effect.void; + if (input.stopError) { + return yield* Effect.fail( + new ProviderSessionManager.ProviderSessionReleaseError({ + providerSessionId, + reason, + cause: new Error("private process diagnostics"), + }), + ); + } + released.push(providerSessionId); + for (const [threadId, list] of sessions) { + const remaining = list.filter((session) => session.id !== providerSessionId); + if (remaining.length === 0) sessions.delete(threadId); + else sessions.set(threadId, remaining); + } + }), + }), + ), + ), + ); + return { + service, + actions, + released, + sessions, + auth, + addInstance: (instance: ProviderInstance) => instances.push(instance), + replaceInstance: (replacement: ProviderInstance) => { + const index = instances.findIndex( + (instance) => instance.instanceId === replacement.instanceId, + ); + assert.isAtLeast(index, 0); + instances[index] = replacement; + }, + replaceAuth: (next: ProviderAuthService.ProviderAuthController) => { + instances[0] = makeInstance({ instanceId, enabled: input.enabled ?? true, auth: next }); + }, + }; +}); + +const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( + function* (flowOwner: string) { + const state = yield* SubscriptionRef.make(idleAuthState); + const close = yield* Deferred.make(); + const closedSubscriptions = yield* Queue.unbounded(); + const unused = () => Effect.die("Unexpected auth operation in a subscription test."); + const auth: ProviderAuthService.ProviderAuthController = { + start: unused, + complete: unused, + cancel: unused, + logout: unused, + subscribe: (ownerSessionId) => + SubscriptionRef.changes(state).pipe( + Stream.map((current) => (ownerSessionId === flowOwner ? current : idleAuthState)), + Stream.interruptWhen(Deferred.await(close)), + Stream.ensuring(Queue.offer(closedSubscriptions, ownerSessionId)), + ), + }; + return { + auth, + state, + closedSubscriptions, + close: Deferred.succeed(close, undefined), + }; + }, +); + +const makeSubscriptionHarness = Effect.fn("ProviderAuthService.test.makeSubscriptionHarness")( + function* (initial: ProviderInstance, replaceDuringFirstLookup?: ProviderInstance) { + const changes = yield* PubSub.unbounded(); + let current: ProviderInstance | undefined = initial; + let pendingReplacement = replaceDuringFirstLookup; + let subscribed = false; + const service = yield* ProviderAuthService.makeProviderAuthService.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + subscribeChanges: Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + subscribed = true; + return subscription; + }), + getInstance: () => + Effect.gen(function* () { + const instance = current; + if (pendingReplacement) { + assert.isTrue(subscribed, "Registry changes must be subscribed before lookup."); + current = pendingReplacement; + pendingReplacement = undefined; + yield* PubSub.publish(changes, undefined); + } + return instance; + }), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({}), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}), + ), + ), + ); + return { + service, + replace: Effect.fn(function* (replacement: ProviderInstance | undefined) { + current = replacement; + yield* PubSub.publish(changes, undefined); + }), + }; + }, +); + +const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* ( + stream: Stream.Stream, +) { + const states = yield* Queue.unbounded(); + const fiber = yield* stream.pipe( + Stream.runForEach((state) => Queue.offer(states, state)), + Effect.forkScoped, + ); + return { states, fiber }; +}); + +describe("ProviderAuthService", () => { + it.effect( + "stops sessions sharing credentials and invalidates their processes before logout", + () => + Effect.gen(function* () { + const { service, actions, sessions } = yield* makeHarness({ + sharedCredentials: true, + ...sessionThreads([ + makeSession("target"), + makeSession("shared", "ready", otherInstanceId), + makeSession("unrelated", "ready", unsupportedInstanceId), + ]), + }); + yield* service.logout({ instanceId }); + assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("unrelated")]); + assert.isBelow(actions.indexOf("stop:shared"), actions.indexOf("invalidate-shared")); + assert.isBelow(actions.indexOf("invalidate-shared"), actions.indexOf("native-logout")); + }), + ); + it.effect("rejects overlapping changes to a shared sign-in", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ + sharedCredentials: true, + sharedBusy: true, + }); + for (const task of [service.start({ instanceId }, owner), service.logout({ instanceId })]) { + const error = yield* task.pipe(Effect.flip); + assert.include(error.detail, "shared sign-in"); + } + assert.deepStrictEqual(actions, []); + }), + ); + it.effect("routes typed interactions to the flow owner and rejects unsupported controllers", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ responds: true }); + yield* service.start({ instanceId }, owner); + const request = { + instanceId, + flowId, + interactionId: "consent", + response: { type: "browser" as const, action: "accept" as const }, + }; + const rejected = yield* service.respond(request, otherOwner).pipe(Effect.flip); + assert.strictEqual(rejected.operation, "respond"); + yield* service.respond(request, owner); + assert.strictEqual(actions.at(-1), "respond:browser"); + const unsupported = yield* makeHarness(); + const error = yield* unsupported.service.respond(request, owner).pipe(Effect.flip); + assert.include(error.detail, "does not accept"); + }), + ); + it.effect("stops routed sessions before sign-in, including for a disabled instance", () => + Effect.gen(function* () { + const { service, actions, released } = yield* makeHarness({ + enabled: false, + threads: [makeThread("active")], + sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]), + }); + const state = yield* service.start({ instanceId }, owner); + + assert.strictEqual(state.instanceId, instanceId); + assert.strictEqual(state.phase, "waiting"); + assert.deepStrictEqual(released, ["active-session"]); + assert.deepStrictEqual(actions, [ + "close-gate", + "list-threads", + "sessions:active", + "stop:active-session", + "start-sign-in", + ]); + }), + ); + + it.effect("keeps sign-in state private and accepts the owner's redirect URL", () => + Effect.gen(function* () { + const { service } = yield* makeHarness(); + const waiting = yield* service.start({ instanceId }, owner); + const ownerStates = yield* service + .subscribe({ instanceId }, owner) + .pipe(Stream.take(1), Stream.runCollect); + const otherStates = yield* service + .subscribe({ instanceId }, otherOwner) + .pipe(Stream.take(1), Stream.runCollect); + + assert.deepStrictEqual(ownerStates, [waiting]); + assert.strictEqual(otherStates[0]?.authorizationUrl, null); + assert.strictEqual(otherStates[0]?.flowId, null); + + const otherError = yield* Effect.flip( + service.complete({ instanceId, flowId, callbackUrl }, otherOwner), + ); + assert.strictEqual(otherError.operation, "complete"); + const complete = yield* service.complete({ instanceId, flowId, callbackUrl }, owner); + assert.strictEqual(complete.phase, "succeeded"); + assert.strictEqual(complete.authorizationUrl, null); + }), + ); + + it.effect("lets only the flow owner cancel sign-in", () => + Effect.gen(function* () { + const { service } = yield* makeHarness(); + yield* service.start({ instanceId }, owner); + const error = yield* Effect.flip(service.cancel({ instanceId, flowId }, otherOwner)); + assert.strictEqual(error.operation, "cancel"); + + const cancelled = yield* service.cancel({ instanceId, flowId }, owner); + assert.strictEqual(cancelled.phase, "cancelled"); + assert.strictEqual(cancelled.authorizationUrl, null); + }), + ); + + it.effect.each([ + { change: "enable", initialEnabled: false, closeBeforeReplacement: true }, + { change: "config", initialEnabled: true, closeBeforeReplacement: false }, + ])( + "keeps private auth subscriptions current after an instance $change change", + ({ initialEnabled, closeBeforeReplacement }) => + Effect.gen(function* () { + const first = yield* makeStreamingController(owner); + const replacement = yield* makeStreamingController(otherOwner); + yield* SubscriptionRef.set(first.state, waitingAuthState); + const { service, replace } = yield* makeSubscriptionHarness( + makeInstance({ instanceId, enabled: initialEnabled, auth: first.auth }), + ); + const firstClient = yield* observeAuth(service.subscribe({ instanceId }, owner)); + const secondClient = yield* observeAuth(service.subscribe({ instanceId }, otherOwner)); + assert.deepStrictEqual(yield* Queue.take(firstClient.states), waitingAuthState); + assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState); + + if (closeBeforeReplacement) { + yield* first.close; + const closed = yield* Queue.takeN(first.closedSubscriptions, 2); + assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner])); + } + yield* replace(makeInstance({ instanceId, enabled: true, auth: replacement.auth })); + assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState); + assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState); + if (!closeBeforeReplacement) { + const closed = yield* Queue.takeN(first.closedSubscriptions, 2); + assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner])); + } + + const staleState: ProviderAuthState = { ...idleAuthState, phase: "cancelled" }; + yield* SubscriptionRef.set(first.state, staleState); + const newWaiting: ProviderAuthState = { + ...waitingAuthState, + flowId: "replacement-flow", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=replacement-state", + }; + yield* SubscriptionRef.set(replacement.state, newWaiting); + assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState); + assert.deepStrictEqual(yield* Queue.take(secondClient.states), newWaiting); + }), + ); + + it.effect("does not miss a replacement during the first instance lookup", () => + Effect.gen(function* () { + const first = yield* makeStreamingController(owner); + const replacement = yield* makeStreamingController(owner); + yield* SubscriptionRef.set(replacement.state, waitingAuthState); + const { service } = yield* makeSubscriptionHarness( + makeInstance({ instanceId, enabled: false, auth: first.auth }), + makeInstance({ instanceId, enabled: true, auth: replacement.auth }), + ); + + const states = yield* service.subscribe({ instanceId }, owner).pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.take(1), + Stream.runCollect, + ); + + assert.deepStrictEqual(states, [waitingAuthState]); + }), + ); + + it.effect("ends the subscription with a setup error when the instance is removed", () => + Effect.gen(function* () { + const controller = yield* makeStreamingController(owner); + const { service, replace } = yield* makeSubscriptionHarness( + makeInstance({ instanceId, enabled: true, auth: controller.auth }), + ); + const { states, fiber } = yield* observeAuth(service.subscribe({ instanceId }, owner)); + assert.deepStrictEqual(yield* Queue.take(states), idleAuthState); + + yield* replace(undefined); + const error = yield* Effect.flip(Fiber.join(fiber)); + + assert.instanceOf(error, ProviderSetupError); + assert.strictEqual(error.instanceId, instanceId); + assert.strictEqual(error.operation, "subscribe"); + assert.include(error.detail, "no longer available"); + assert.strictEqual(yield* Queue.take(controller.closedSubscriptions), owner); + }), + ); + + it.effect.each([ + { id: ProviderInstanceId.make("missing"), detail: "no longer available" }, + { id: unsupportedInstanceId, detail: "does not support sign-in" }, + ])("rejects setup for unavailable or unsupported instance $id", ({ id, detail }) => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness(); + const operations: Effect.Effect< + ProviderAuthState | ReadonlyArray, + ProviderSetupError + >[] = [ + service.start({ instanceId: id }, owner), + service.complete({ instanceId: id, flowId, callbackUrl }, owner), + service.cancel({ instanceId: id, flowId }, owner), + service.logout({ instanceId: id }), + Stream.runCollect(service.subscribe({ instanceId: id }, owner)), + ]; + for (const operation of operations) { + const error = yield* Effect.flip(operation); + assert.instanceOf(error, ProviderSetupError); + assert.strictEqual(error.instanceId, id); + assert.include(error.detail, detail); + } + assert.deepStrictEqual(actions, []); + }), + ); + + it.effect("stops every live session on the instance before native logout", () => + Effect.gen(function* () { + const { service, actions, released } = yield* makeHarness({ + threads: [ + makeThread("running"), + makeThread("starting"), + makeThread("idle", { active: false }), + makeThread("other-instance", { providerInstanceId: otherInstanceId }), + ], + sessions: new Map([ + [ + ThreadId.make("running"), + [ + makeSession("running-session", "running"), + makeSession("stopped-session", "stopped"), + makeSession("errored-session", "error"), + makeSession("foreign-session", "ready", otherInstanceId), + ], + ], + [ThreadId.make("starting"), [makeSession("starting-session", "starting")]], + [ThreadId.make("idle"), [makeSession("idle-session")]], + [ + ThreadId.make("other-instance"), + [makeSession("other-session", "ready", otherInstanceId)], + ], + ]), + }); + const state = yield* service.logout({ instanceId }); + + assert.strictEqual(state.phase, "idle"); + assert.deepStrictEqual(actions, [ + "close-gate", + "list-threads", + "sessions:running", + "stop:running-session", + "sessions:starting", + "stop:starting-session", + "sessions:idle", + "stop:idle-session", + "sessions:other-instance", + "native-logout", + ]); + assert.deepStrictEqual(released, ["running-session", "starting-session", "idle-session"]); + }), + ); + + it.effect( + "releases a shared native session once after a thread's selected provider changes", + () => + Effect.gen(function* () { + const shared = makeSession("shared-native-session"); + const { service, released } = yield* makeHarness({ + threads: [ + makeThread("changed-selection", { providerInstanceId: otherInstanceId }), + makeThread("same-session"), + ], + sessions: new Map([ + [ThreadId.make("changed-selection"), [shared]], + [ThreadId.make("same-session"), [shared]], + ]), + }); + + yield* service.logout({ instanceId }); + + assert.deepStrictEqual(released, [shared.id]); + }), + ); + + it.effect.each([ + { text: "/logout", hasAttachments: false, handled: true }, + { text: " \n/logout\t", hasAttachments: false, handled: true }, + { text: "/logout", hasAttachments: true, handled: false }, + { text: "/logout please", hasAttachments: false, handled: false }, + { text: "Explain /logout", hasAttachments: false, handled: false }, + { text: "/Logout", hasAttachments: false, handled: false }, + ])("handles only a standalone logout command %#", ({ text, hasAttachments, handled }) => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness(); + const result = yield* service.tryHandlePromptCommand({ instanceId, text, hasAttachments }); + + assert.strictEqual(result, handled); + assert.deepStrictEqual( + actions, + handled ? ["close-gate", "list-threads", "native-logout"] : [], + ); + }), + ); + + it.effect("does not intercept commands for providers without an auth controller", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness(); + for (const id of [unsupportedInstanceId, ProviderInstanceId.make("missing")]) { + assert.isFalse( + yield* service.tryHandlePromptCommand({ + instanceId: id, + text: "/logout", + hasAttachments: false, + }), + ); + } + assert.deepStrictEqual(actions, []); + }), + ); + + it.effect("does not log out when the thread shell cannot be read", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ shellError: true }); + const error = yield* Effect.flip(service.logout({ instanceId })); + + assert.instanceOf(error, ProviderSetupError); + assert.strictEqual(error.instanceId, instanceId); + assert.strictEqual(error.operation, "stopSessions"); + assert.notInclude(error.detail, "private database diagnostics"); + assert.deepStrictEqual(actions, ["close-gate", "list-threads"]); + }), + ); + + it.effect("does not log out or consume the command when stopping a session fails", () => + Effect.gen(function* () { + const { service, actions, released } = yield* makeHarness({ + threads: [makeThread("active")], + sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]), + stopError: true, + }); + const error = yield* Effect.flip( + service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), + ); + + assert.instanceOf(error, ProviderSetupError); + assert.strictEqual(error.operation, "stopSessions"); + assert.notInclude(error.detail, "private process diagnostics"); + assert.deepStrictEqual(released, []); + assert.deepStrictEqual(actions, [ + "close-gate", + "list-threads", + "sessions:active", + "stop:active-session", + ]); + }), + ); + + it.effect("returns native logout failures to the command caller", () => + Effect.gen(function* () { + const logoutError = new ProviderSetupError({ + instanceId, + operation: "logout", + detail: "Native sign-out failed. Try again.", + }); + const { service } = yield* makeHarness({ logoutError }); + const error = yield* Effect.flip( + service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), + ); + + assert.strictEqual(error, logoutError); + }), + ); +}); + +it.effect("queued logout prompts resolve the current controller after provider replacement", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const lookup = yield* Deferred.make(); + let observeLookup = false; + const harness = yield* makeHarness({ + beforeLogout: Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ), + onLookup: Effect.suspend(() => + observeLookup ? Deferred.succeed(lookup, undefined).pipe(Effect.asVoid) : Effect.void, + ), + }); + const first = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(entered); + observeLookup = true; + const queued = yield* harness.service + .tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }) + .pipe(Effect.forkChild); + yield* Deferred.await(lookup); + let replacementLoggedOut = false; + harness.replaceAuth({ + ...harness.auth, + logout: (stopSessions) => + stopSessions.pipe( + Effect.andThen( + Effect.sync(() => { + replacementLoggedOut = true; + return idleAuthState; + }), + ), + ), + }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + assert.isTrue(yield* Fiber.join(queued)); + assert.isTrue(replacementLoggedOut); + assert.strictEqual(harness.actions.filter((action) => action === "native-logout").length, 1); + }).pipe(Effect.scoped), +); + +it.effect.each(["start", "logout", "prompt"] as const)( + "%s uses the same credential controller for shared exclusion and mutation during replacement", + (action) => + Effect.gen(function* () { + const checked = yield* Deferred.make(); + const continueCheck = yield* Deferred.make(); + const replacementPeerId = ProviderInstanceId.make("replacement-shared-peer"); + const harness = yield* makeHarness({ + sharedCredentials: true, + sharedBusyEffect: Deferred.succeed(checked, undefined).pipe( + Effect.andThen(Deferred.await(continueCheck)), + Effect.as(false), + ), + ...sessionThreads([ + makeSession("old-shared", "ready", otherInstanceId), + makeSession("replacement-shared", "ready", replacementPeerId), + ]), + }); + harness.addInstance( + makeInstance({ + instanceId: replacementPeerId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-binding" }, + isChangingCredentials: Effect.succeed(true), + invalidate: Effect.die( + "The unrelated replacement credential binding must stay intact.", + ), + }, + }), + ); + const operation = + action === "start" + ? harness.service.start({ instanceId }, owner) + : action === "logout" + ? harness.service.logout({ instanceId }) + : harness.service.tryHandlePromptCommand({ + instanceId, + text: "/logout", + hasAttachments: false, + }); + const running = yield* operation.pipe(Effect.forkChild); + yield* Deferred.await(checked); + let replacementMutations = 0; + harness.replaceAuth({ + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-binding" }, + start: () => + Effect.sync(() => { + replacementMutations++; + return waitingAuthState; + }), + logout: () => + Effect.sync(() => { + replacementMutations++; + return idleAuthState; + }), + }); + yield* Deferred.succeed(continueCheck, undefined); + yield* Fiber.join(running); + assert.equal(replacementMutations, 0); + assert.include(harness.actions, action === "start" ? "start-sign-in" : "native-logout"); + assert.isFalse(harness.sessions.has(ThreadId.make("old-shared"))); + assert.isTrue(harness.sessions.has(ThreadId.make("replacement-shared"))); + const blocked = yield* Effect.flip(harness.service.logout({ instanceId })); + assert.include(blocked.detail, "shared sign-in"); + assert.equal(replacementMutations, 0); + }).pipe(Effect.scoped), +); + +it.effect.each([ + { owner: "provider" as const, key: "different-binding" }, + { owner: "t3" as const, key: "shared" }, +])( + "does not invalidate a peer that switches credential binding during session draining %#", + (binding) => + Effect.gen(function* () { + const draining = yield* Deferred.make(); + const continueDrain = yield* Deferred.make(); + const harness = yield* makeHarness({ + sharedCredentials: true, + ...sessionThreads([makeSession("shared-draining", "ready", otherInstanceId)]), + beforeStop: Deferred.succeed(draining, undefined).pipe( + Effect.andThen(Deferred.await(continueDrain)), + ), + }); + const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(draining); + let replacementInvalidated = false; + harness.replaceInstance( + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: binding, + invalidate: Effect.sync(() => { + replacementInvalidated = true; + }), + }, + }), + ); + yield* Deferred.succeed(continueDrain, undefined); + yield* Fiber.join(logout); + assert.isFalse(replacementInvalidated); + assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining"))); + assert.include(harness.actions, "native-logout"); + }).pipe(Effect.scoped), +); + +it.effect.each(["selection", "drain"] as const)( + "preserves replacement peer sessions when credentials change during session %s", + (phase) => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const proceed = yield* Deferred.make(); + const block = Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(proceed)), + ); + const harness = yield* makeHarness({ + sharedCredentials: true, + ...sessionThreads([ + makeSession("target-draining"), + makeSession("peer-replacement", "ready", otherInstanceId), + makeSession("peer-persisted", "running", otherInstanceId), + ]), + ...(phase === "selection" ? { beforeListSessions: block } : { beforeStop: block }), + }); + const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(entered); + harness.replaceInstance( + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-credentials" }, + invalidate: Effect.die("The replacement peer's credentials must not be invalidated."), + }, + }), + ); + yield* Deferred.succeed(proceed, undefined); + yield* Fiber.join(logout); + assert.isTrue(harness.sessions.has(ThreadId.make("peer-replacement"))); + assert.notInclude(harness.actions, "stop:peer-replacement"); + assert.notInclude(harness.actions, "stop:peer-persisted"); + assert.isFalse(harness.sessions.has(ThreadId.make("target-draining"))); + assert.include(harness.actions, "native-logout"); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/provider/ProviderAuthService.ts b/apps/server/src/provider/ProviderAuthService.ts new file mode 100644 index 0000000000000000000000000000000000000000..9dabd57104ca6d01203f9aaa6286c016a751d5bb --- /dev/null +++ b/apps/server/src/provider/ProviderAuthService.ts @@ -0,0 +1,348 @@ +import { + ProviderSetupError, + type ChatGptReconnectProfile, + type ChatGptTransferredProfile, + type ProviderAuthRespondInput, + type ProviderAuthStartInput, + type ProviderAuthState, + type ProviderInstanceId, + type ProviderSessionId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as Semaphore from "effect/Semaphore"; + +import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; + +export interface ProviderAuthController { + /** Equal keys mean these instances share credentials on this environment. */ + readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; + readonly reconnectProfile?: ( + methodId: string, + ) => Effect.Effect; + readonly importProfile?: ( + profile: ChatGptTransferredProfile, + stopSessions: Effect.Effect, + ) => Effect.Effect; + readonly adoptCredentials?: ( + update: Effect.Effect, + stopSessions: Effect.Effect, + ) => Effect.Effect; + readonly isChangingCredentials?: Effect.Effect; + readonly invalidate?: Effect.Effect; + readonly refreshMethods?: Effect.Effect; + readonly withAccess?: ( + task: Effect.Effect, + ) => Effect.Effect; + readonly start: ( + ownerSessionId: string, + stopSessions?: Effect.Effect, + methodId?: string, + returnUrl?: string, + callbackMode?: "server" | "client", + ) => Effect.Effect; + readonly complete: ( + ownerSessionId: string, + input: { readonly flowId: string; readonly callbackUrl: string }, + ) => Effect.Effect; + readonly cancel: ( + ownerSessionId: string, + flowId: string, + ) => Effect.Effect; + readonly respond?: ( + ownerSessionId: string, + input: ProviderAuthRespondInput, + ) => Effect.Effect; + /** The controller closes process admission before it stops routed sessions. */ + readonly logout: ( + stopSessions: Effect.Effect, + ) => Effect.Effect; + readonly subscribe: (ownerSessionId: string) => Stream.Stream; + readonly isLogoutPrompt?: (text: string, hasAttachments: boolean) => boolean; +} + +interface ProviderAuthTarget { + readonly instanceId: ProviderInstanceId; +} + +export class ProviderAuthService extends Context.Service< + ProviderAuthService, + { + readonly reconnectProfile: ( + input: ProviderAuthTarget & { methodId: string }, + ) => Effect.Effect; + readonly importProfile: ( + input: ProviderAuthTarget & { profile: ChatGptTransferredProfile }, + ) => Effect.Effect; + readonly start: ( + input: ProviderAuthStartInput, + ownerSessionId: string, + ) => Effect.Effect; + readonly complete: ( + input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, + ownerSessionId: string, + ) => Effect.Effect; + readonly respond: ( + input: ProviderAuthRespondInput, + ownerSessionId: string, + ) => Effect.Effect; + readonly cancel: ( + input: ProviderAuthTarget & { readonly flowId: string }, + ownerSessionId: string, + ) => Effect.Effect; + readonly logout: ( + input: ProviderAuthTarget, + ) => Effect.Effect; + readonly subscribe: ( + input: ProviderAuthTarget, + ownerSessionId: string, + ) => Stream.Stream; + readonly tryHandlePromptCommand: ( + input: ProviderAuthTarget & { readonly text: string; readonly hasAttachments: boolean }, + ) => Effect.Effect; + } +>()("t3/provider/ProviderAuthService") {} + +export const makeProviderAuthService = Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const credentialChanges = yield* Semaphore.make(1); + + const getController = Effect.fn("ProviderAuthService.getController")(function* ( + instanceId: ProviderInstanceId, + operation: string, + ) { + const instance = yield* registry.getInstance(instanceId); + if (!instance?.auth) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: instance + ? "This provider does not support sign-in in T3 Code." + : "This provider instance is no longer available.", + }); + } + return instance.auth; + }); + + // Native sessions may still belong to the previous provider after the + // selected model changes. Read session bindings, not the selected model, + // when invalidating credentials for sign-in or sign-out. + const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( + instanceId: ProviderInstanceId, + binding: ProviderAuthController["credentialBinding"], + ) { + const failure = (detail: string) => + new ProviderSetupError({ instanceId, operation: "stopSessions", detail }); + const affectedIds = new Set([ + instanceId, + ...(binding === undefined + ? [] + : (yield* registry.listInstances) + .filter( + (instance) => + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner, + ) + .map((instance) => instance.instanceId)), + ]); + const threadIds = yield* projections + .getRecoveryThreadIds("runtime") + .pipe( + Effect.mapError(() => failure("Could not read the provider's active sessions. Try again.")), + ); + const released = new Set(); + yield* Effect.forEach( + threadIds, + (threadId) => + projections.getThreadRecords(threadId, ["providerSessions"]).pipe( + Effect.flatMap((projection) => + Effect.forEach( + projection.providerSessions.filter( + (session) => + affectedIds.has(session.providerInstanceId) && + session.status !== "stopped" && + session.status !== "error" && + !released.has(session.id), + ), + (session) => + Effect.gen(function* () { + if (session.providerInstanceId !== instanceId) { + const current = yield* registry.getInstance(session.providerInstanceId); + if ( + !binding || + current?.auth?.credentialBinding?.key !== binding.key || + current.auth.credentialBinding.owner !== binding.owner + ) + return; + } + yield* providerSessions + .release({ + providerSessionId: session.id, + reason: "manual_shutdown", + detail: "Provider sign-in changed.", + }) + .pipe(Effect.tap(() => Effect.sync(() => released.add(session.id)))); + }), + { discard: true }, + ), + ), + Effect.mapError(() => + failure("Could not stop all sessions for this provider. Try again."), + ), + ), + { discard: true }, + ); + if (binding) { + yield* Effect.forEach( + (yield* registry.listInstances).filter( + (instance) => + instance.instanceId !== instanceId && + affectedIds.has(instance.instanceId) && + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner, + ), + (instance) => instance.auth?.invalidate ?? Effect.void, + { discard: true }, + ); + } + }); + + const checkSharedBinding = Effect.fnUntraced(function* ( + instanceId: ProviderInstanceId, + operation: "start" | "logout", + auth: ProviderAuthController, + ) { + const binding = auth.credentialBinding; + if (!binding) return; + const instances = yield* registry.listInstances; + for (const instance of instances) { + if ( + instance.instanceId !== instanceId && + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner && + instance.auth.isChangingCredentials && + (yield* instance.auth.isChangingCredentials) + ) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: + "Another provider instance is changing this shared sign-in. Finish or cancel it first.", + }); + } + } + }); + + return ProviderAuthService.of({ + reconnectProfile: Effect.fnUntraced(function* (input) { + const auth = yield* getController(input.instanceId, "export"); + if (!auth.reconnectProfile) + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "export", + detail: "This provider does not support ChatGPT profile transfer.", + }); + return yield* auth.reconnectProfile(input.methodId); + }), + importProfile: (input) => + credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "import"); + yield* checkSharedBinding(input.instanceId, "start", auth); + if (!auth.importProfile) + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "import", + detail: "This provider does not support ChatGPT profile transfer.", + }); + return yield* auth.importProfile( + input.profile, + stopSessions(input.instanceId, auth.credentialBinding), + ); + }), + ), + start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "start"); + yield* checkSharedBinding(input.instanceId, "start", auth); + return yield* auth.start( + ownerSessionId, + stopSessions(input.instanceId, auth.credentialBinding), + input.methodId, + input.returnUrl, + input.callbackMode, + ); + }), + ); + }), + respond: Effect.fn("ProviderAuthService.respond")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "respond"); + if (!auth.respond) { + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "respond", + detail: "This provider does not accept this sign-in interaction.", + }); + } + return yield* auth.respond(ownerSessionId, input); + }), + complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "complete"); + return yield* auth.complete(ownerSessionId, input); + }), + cancel: Effect.fn("ProviderAuthService.cancel")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "cancel"); + return yield* auth.cancel(ownerSessionId, input.flowId); + }), + logout: Effect.fn("ProviderAuthService.logout")(function* (input) { + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "logout"); + yield* checkSharedBinding(input.instanceId, "logout", auth); + return yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); + }), + ); + }), + subscribe: (input, ownerSessionId) => + Effect.gen(function* () { + const changes = yield* registry.subscribeChanges; + const initial = yield* getController(input.instanceId, "subscribe"); + return Stream.concat( + Stream.succeed(initial), + Stream.fromSubscription(changes).pipe( + Stream.mapEffect(() => getController(input.instanceId, "subscribe")), + ), + ).pipe( + Stream.changesWith((previous, next) => previous === next), + Stream.switchMap((auth) => auth.subscribe(ownerSessionId)), + ); + }).pipe(Stream.unwrap), + tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")( + function* (input) { + const instance = yield* registry.getInstance(input.instanceId); + if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { + return false; + } + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "logout"); + if (!auth.isLogoutPrompt?.(input.text, input.hasAttachments)) return false; + yield* checkSharedBinding(input.instanceId, "logout", auth); + yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); + return true; + }), + ); + }, + ), + }); +}); + +export const layer = Layer.effect(ProviderAuthService, makeProviderAuthService); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index cb86075616d13c0401e56096c5515b1e91842e54..d51061731b509d96b75959219b1bf9241c9a4b8c 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -40,8 +40,8 @@ import type * as Scope from "effect/Scope"; import type { TextGeneration } from "../textGeneration/TextGeneration.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import type { ProviderDriverError } from "./Errors.ts"; -import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; -import type { ServerProviderShape } from "./Services/ServerProvider.ts"; +import type { ProviderAuthController } from "./ProviderAuthService.ts"; +import type { ServerProviderShape } from "./ServerProvider.ts"; /** * Static metadata advertised by a driver. Used for default presentation diff --git a/apps/server/src/provider/ProviderEventLoggers.ts b/apps/server/src/provider/ProviderEventLoggers.ts new file mode 100644 index 0000000000000000000000000000000000000000..1cb96058d3b2afb517a9142bda66506bbd7c1798 --- /dev/null +++ b/apps/server/src/provider/ProviderEventLoggers.ts @@ -0,0 +1,92 @@ +/** + * ProviderEventLoggers — single observability service that owns the shared + * provider event log store and exposes its two runtime views: + * + * - `native` — provider-protocol events as the SDK emits them, written + * from inside each `Adapter` factory. + * - `canonical` — runtime events after `ProviderService` has normalized + * them onto `ProviderRuntimeEvent`. + * + * Why a service tag and not constructor options? + * + * - Adapters are now constructed *inside* drivers (`Driver.create()`), + * not at the boot Layer. There is no longer a single `makeAdapterLive(options)` + * call site where we can hand an `EventNdjsonLogger` in by hand. + * - Multiple driver instances per kind (`codex_personal`, `codex_work`) + * must share one underlying log store — opening N writers against the + * same rotating file would race the rotation logic. Owning the loggers on + * a single tag keeps that invariant intact. + * - Tests can swap one (or both) loggers with in-memory recorders by + * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of + * juggling per-Layer option threading. + * + * Both fields are optional because observability must not prevent startup. + * + * @module provider/ProviderEventLoggers + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; +import * as EventNdjsonLogger from "./EventNdjsonLogger.ts"; + +/** + * Shared logger pair for native + canonical provider event streams. + * + * Service value is intentionally a struct of two optional loggers rather + * than two parallel tags. Construction site is one place + * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the + * field they need. + */ +export class ProviderEventLoggers extends Context.Service< + ProviderEventLoggers, + { + readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined; + readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined; + } +>()("t3/provider/ProviderEventLoggers") {} + +/** + * Constant value used by tests / boot layers that want to opt out of native + * + canonical logging entirely. Keeps the tag non-optional in the type + * system while letting the runtime treat absence as a no-op. + */ +export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { + native: undefined, + canonical: undefined, +}; + +/** + * Builds both stream views over one shared store. Setup failures are logged + * and downgraded to the no-op service so diagnostics never block startup. + * + * @public Service construction is part of the canonical Effect module API. + */ +export const make = Effect.gen(function* () { + const { providerEventLogPath } = yield* ServerConfig.ServerConfig; + const attribution = yield* ResourceAttribution.ResourceAttribution; + const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, { + attribution, + }).pipe( + Effect.catch((error) => + Effect.logWarning(error.message, { error }).pipe( + Effect.annotateLogs({ scope: "provider-observability" }), + Effect.as(undefined), + ), + ), + ); + + if (!store) { + return ProviderEventLoggers.of(NoOpProviderEventLoggers); + } + + yield* Effect.addFinalizer(() => store.close()); + return ProviderEventLoggers.of({ + native: store.logger("native"), + canonical: store.logger("canonical"), + }); +}); + +export const layer = Layer.effect(ProviderEventLoggers, make); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d78de9ecf44eae00b8cd21b8fca775283b5ed415 --- /dev/null +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -0,0 +1,781 @@ +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as CodexInstallation from "./CodexInstallation.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +/** + * Multi-instance validation slices for the live `ProviderInstanceRegistry`. + * + * Two axes of the driver/registry refactor are exercised here: + * + * 1. **Same driver, many instances** — the "multi-instance codex slice" + * describe block below configures two independent `codex` instances and + * asserts each gets its own closures and identity. This is the + * multi-codex capability the refactor exists to unlock. + * + * 2. **Many drivers, one registry** — the "all drivers slice" describe + * block below configures one instance of every shipped driver + * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * `ProviderInstanceConfigMap` and asserts the registry boots them all + * without cross-contamination. This proves the driver SPI is uniform + * across every provider — any driver plugs into the registry through + * the same `ProviderDriver` value contract. + * + * Every instance in these tests is configured with `enabled: false` so the + * provider-status checks short-circuit to pending/disabled snapshots + * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` + * binaries. That keeps the assertions focused on registry routing + * behaviour rather than the runtime details of each provider. + */ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentId, + type ClaudeSettings, + type CodexSettings, + type CursorSettings, + type GrokSettings, + type OpenCodeSettings, + ProviderDriverKind, + type ProviderInstanceConfigMap, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as AntigravityInstallation from "./AntigravityInstallation.ts"; +import * as ServerConfig from "../config.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; +import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; +import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import * as ModelManifest from "./ModelManifest.ts"; +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; +import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; +import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; +import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; + +const layerTestHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), + ), +); + +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + +const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + reportClientActivity: () => Effect.void, + removeRpcClient: () => Effect.void, + reportHostPowerState: () => Effect.void, + snapshot: Effect.succeed({ + hostPower: { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: TEST_EPOCH, + }, + leases: [], + activeForegroundLeaseCount: 0, + activeScopeKeys: [], + shouldRunOpportunisticWork: true, + updatedAt: TEST_EPOCH, + }), + streamChanges: Stream.empty, + hasDemand: () => Effect.succeed(true), + shouldRunScopeWork: () => Effect.succeed(true), + shouldRunOpportunisticWork: Effect.succeed(true), +}); + +const makeCodexConfig = (overrides: Partial): CodexSettings => ({ + enabled: false, + binaryPath: "codex", + homePath: "", + shadowHomePath: "", + launchArgs: "", + customModels: [], + ...overrides, +}); + +const makeClaudeConfig = (overrides: Partial): ClaudeSettings => ({ + enabled: false, + binaryPath: "claude", + homePath: "", + customModels: [], + launchArgs: "", + autoCompactWindow: "", + ...overrides, +}); + +const makeCursorConfig = (overrides: Partial): CursorSettings => ({ + enabled: false, + customModels: [], + ...overrides, +}); + +const makeGrokConfig = (overrides: Partial): GrokSettings => ({ + enabled: false, + binaryPath: "grok", + customModels: [], + ...overrides, +}); + +const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ + enabled: false, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + ...overrides, +}); + +const makeTildeProviderFixtures = Effect.fn( + "ProviderInstanceRegistry.test.makeTildeProviderFixtures", +)(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = expandHomePath("~"); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + directory: homePath, + prefix: ".t3-provider-path-test-", + }); + const codexPath = path.join(fixtureDir, "codex"); + const claudePath = path.join(fixtureDir, "claude"); + const claudeHomePath = path.join(fixtureDir, "claude-home"); + const codexScriptPath = path.join(fixtureDir, "codex-script.json"); + const codexFixtureDir = path.join(import.meta.dirname, "testFixtures"); + + yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), + path.join(fixtureDir, "codexCollabMockPeer.mjs"), + ); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexMultiAgentWire.json"), + path.join(fixtureDir, "codexMultiAgentWire.json"), + ); + yield* fileSystem.writeFileString( + codexScriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. + JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), + ); + yield* fileSystem.chmod(codexPath, 0o755); + + yield* fileSystem.copyFile( + yield* path.fromFileUrl( + new URL("./testing/ProviderInstanceRegistryLive.fixture.mjs", import.meta.url), + ), + claudePath, + ); + yield* fileSystem.chmod(claudePath, 0o755); + yield* fileSystem.makeDirectory(claudeHomePath); + + const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; + return { + codexBinaryPath: asTildePath(codexPath), + claudeBinaryPath: asTildePath(claudePath), + claudeHomePath, + codexScriptPath, + }; +}); + +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 layerBase = ServerConfig.layerTest(process.cwd(), { + prefix: "provider-instance-registry-test", + }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + Layer.mock(CodexInstallation.CodexInstallation)({ + managedDirectory: "unused-managed-installation", + }), + ), + Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), + Layer.provideMerge( + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), + ), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge(layerTestHttpClient), + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), + ); + const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( + Layer.provideMerge(layerBase), + ); + + it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => + Effect.gen(function* () { + const personalId = ProviderInstanceId.make("codex_personal"); + const workId = ProviderInstanceId.make("codex_work"); + const codexDriverKind = ProviderDriverKind.make("codex"); + + const configMap: ProviderInstanceConfigMap = { + [personalId]: { + driver: codexDriverKind, + displayName: "Codex (personal)", + enabled: false, + config: makeCodexConfig({ + binaryPath: "/opt/codex-personal/bin/codex", + homePath: "/home/julius/.codex_personal", + customModels: ["personal-preview"], + }), + }, + [workId]: { + driver: codexDriverKind, + displayName: "Codex (work)", + enabled: false, + config: makeCodexConfig({ + binaryPath: "/opt/codex-work/bin/codex", + homePath: "/home/julius/.codex", + customModels: ["work-preview"], + }), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap, + }); + + const instances = yield* registry.listInstances; + expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( + [personalId, workId].toSorted(), + ); + expect(instances.every((instance) => instance.driverKind === codexDriverKind)).toBe(true); + expect(instances.map((instance) => instance.displayName).toSorted()).toEqual( + ["Codex (personal)", "Codex (work)"].toSorted(), + ); + + // Each instance must be retrievable by id and carry its *own* closures. + const personal = yield* registry.getInstance(personalId); + const work = yield* registry.getInstance(workId); + expect(personal).toBeDefined(); + expect(work).toBeDefined(); + expect(personal!.orchestrationAdapter).not.toBe(work!.orchestrationAdapter); + expect(personal!.textGeneration).not.toBe(work!.textGeneration); + expect(personal!.snapshot).not.toBe(work!.snapshot); + + // Snapshots identify themselves by instanceId + driver — this is + // what makes per-instance routing distinguishable downstream. + const personalSnapshot = yield* personal!.snapshot.getSnapshot; + expect(personalSnapshot.instanceId).toBe(personalId); + expect(personalSnapshot.driver).toBe(codexDriverKind); + expect(personalSnapshot.enabled).toBe(false); + // The layout resolves the configured home through the host Path. + const path = yield* Path.Path; + expect(personalSnapshot.continuation?.groupKey).toBe( + `codex:home:${path.resolve("/home/julius/.codex_personal")}`, + ); + + const workSnapshot = yield* work!.snapshot.getSnapshot; + expect(workSnapshot.instanceId).toBe(workId); + expect(workSnapshot.driver).toBe(codexDriverKind); + expect(workSnapshot.enabled).toBe(false); + expect(workSnapshot.continuation?.groupKey).toBe( + `codex:home:${path.resolve("/home/julius/.codex")}`, + ); + + // Nothing goes to the unavailable bucket — both drivers are registered. + const unavailable = yield* registry.listUnavailable; + expect(unavailable).toEqual([]); + }).pipe(Effect.provide(layerTest)), + ); + + it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () => + Effect.gen(function* () { + // Old settings files can carry both flags with conflicting values. + // The explicit false must win so a user's disable is never undone. + const staleId = ProviderInstanceId.make("codex_stale"); + const configMap: ProviderInstanceConfigMap = { + [staleId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: makeCodexConfig({ enabled: false }), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap, + }); + + const instance = yield* registry.getInstance(staleId); + expect(instance).toBeDefined(); + expect(instance!.enabled).toBe(false); + const snapshot = yield* instance!.snapshot.getSnapshot; + expect(snapshot.enabled).toBe(false); + }).pipe(Effect.provide(layerTest)), + ); + + it.live("reports Codex's answer when a redemption changed nothing", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + const fileSystem = yield* FileSystem.FileSystem; + const fixtures = yield* makeTildeProviderFixtures(); + yield* fileSystem.writeFileString( + fixtures.codexScriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. + JSON.stringify({ + rootThreadId: "probe-thread", + notifications: [], + account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, + failRateLimitsRead: true, + resetCreditOutcome: "alreadyRedeemed", + }), + ); + const codexId = ProviderInstanceId.make("codex_reset"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { name: "T3_CODEX_COLLAB_SCRIPT", value: fixtures.codexScriptPath, sensitive: false }, + ], + config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), + }, + }, + }); + const codex = yield* registry.getInstance(codexId); + expect(codex).toBeDefined(); + // The usage read fails, so the re-probe cannot confirm new limits. + yield* codex!.snapshot.refresh; + expect(yield* codex!.consumeResetCredit!()).toBe("alreadyRedeemed"); + }).pipe(Effect.provide(layerTest)), + ); + + it.live("runs Codex and Claude readiness probes from configured tilde paths", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + + const fixtures = yield* makeTildeProviderFixtures(); + + const codexId = ProviderInstanceId.make("codex_tilde"); + const claudeId = ProviderInstanceId.make("claude_tilde"); + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { + name: "T3_CODEX_COLLAB_SCRIPT", + value: fixtures.codexScriptPath, + sensitive: false, + }, + ], + config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), + }, + [claudeId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver, ClaudeDriver], + configMap, + }); + const codex = yield* registry.getInstance(codexId); + const claude = yield* registry.getInstance(claudeId); + expect(codex).toBeDefined(); + expect(claude).toBeDefined(); + + const [codexSnapshot, claudeSnapshot] = yield* Effect.all( + [codex!.snapshot.refresh, claude!.snapshot.refresh], + { concurrency: "unbounded" }, + ); + expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); + expect(claudeSnapshot).toMatchObject({ + status: "ready", + installed: true, + version: "2.1.219", + }); + }).pipe(Effect.provide(layerTest)), + ); + + const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixtures = yield* makeTildeProviderFixtures(); + const marker = path.join(fixtures.claudeHomePath, "redeemed"); + yield* fs.writeFileString( + path.join(fixtures.claudeHomePath, ".credentials.json"), + '{"claudeAiOauth":{"accessToken":"fake-token"}}', + ); + yield* fs.writeFileString( + path.join(fixtures.claudeHomePath, ".claude.json"), + '{"oauthAccount":{"organizationUuid":"fake-org"}}', + ); + const client = HttpClient.make((request) => + Effect.gen(function* () { + if (request.url.endsWith("/api/oauth/usage")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { + eligible: true, + next_grant_id: "grant_a", + grants: [{ id: "grant_a", resets_left: 1, usable_now: true }], + }, + }), + ); + } + if (request.url.endsWith("/reset_rate_limits")) { + yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie); + return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result })); + } + return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" })); + }), + ); + const instanceId = ProviderInstanceId.make("claude_reset"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [ClaudeDriver], + configMap: { + [instanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + environment: [ + { name: "T3_CLAUDE_RESET_MARKER", value: marker, sensitive: false }, + ...(claim.usageFailsAfterClaim + ? [{ name: "T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM", value: "1", sensitive: false }] + : []), + ], + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }, + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + const instance = yield* registry.getInstance(instanceId); + expect(instance).toBeDefined(); + const before = yield* instance!.snapshot.refresh; + expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); + expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); + const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); + return { outcome, after: yield* instance!.snapshot.getSnapshot }; + }).pipe( + // macOS logins live in the Keychain, where resets are never read. + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide(layerTest), + ); + + it.live("refreshes Claude usage after redeeming a reset", () => + Effect.gen(function* () { + const { outcome, after } = yield* redeemClaudeReset({ + result: "reset", + usageFailsAfterClaim: false, + }); + expect(outcome).toMatchObject({ _tag: "Success", success: "reset" }); + expect(after.usageLimits?.windows[0]?.usedPercent).toBe(0); + }), + ); + + it.live("reports Claude's answer when a claim changed nothing and the re-probe fails", () => + Effect.gen(function* () { + const { outcome } = yield* redeemClaudeReset({ + result: "already_used", + usageFailsAfterClaim: true, + }); + expect(outcome).toMatchObject({ _tag: "Success", success: "alreadyRedeemed" }); + }), + ); + + it.live( + "shadows instances whose driver is not registered in this build without failing boot", + () => + Effect.gen(function* () { + const codexId = ProviderInstanceId.make("codex_main"); + const ghostId = ProviderInstanceId.make("ghost_main"); + + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: makeCodexConfig({}), + }, + [ghostId]: { + driver: ProviderDriverKind.make("ghostDriver"), + displayName: "A fork-only driver we don't ship", + enabled: false, + config: { arbitrary: "payload", preserved: true }, + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap, + }); + + const instances = yield* registry.listInstances; + expect(instances).toHaveLength(1); + expect(instances[0]!.instanceId).toBe(codexId); + + const unavailable = yield* registry.listUnavailable; + expect(unavailable).toHaveLength(1); + const ghost = unavailable[0]!; + expect(ghost.instanceId).toBe(ghostId); + expect(ghost.driver).toBe("ghostDriver"); + expect(ghost.availability).toBe("unavailable"); + expect(ghost.unavailableReason).toMatch(/ghostDriver/); + }).pipe(Effect.provide(layerTest)), + ); +}); + +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 + // `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 + // surfaced; that merged layer then provides `ServerConfig.layerTest`'s + // `FileSystem` dep while keeping everything else surfaced to the test. + const layerInfra = OpenCodeRuntime.layer.pipe( + Layer.provide(OpenCodeServerLedger.layerTest), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + Layer.mock(CodexInstallation.CodexInstallation)({ + managedDirectory: "unused-managed-installation", + }), + ), + Layer.provideMerge( + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), + ), + ); + const layerBase = AntigravityInstallation.AntigravityInstallation.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "provider-instance-registry-all-drivers-test", + }), + ), + Layer.provideMerge(layerInfra), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge(layerTestHttpClient), + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), + ); + const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( + Layer.provideMerge(layerBase), + ); + + it.live("boots one instance of every shipped driver from a single config map", () => + Effect.gen(function* () { + const codexId = ProviderInstanceId.make("codex_default"); + const claudeId = ProviderInstanceId.make("claude_default"); + const cursorId = ProviderInstanceId.make("cursor_default"); + const grokId = ProviderInstanceId.make("grok_default"); + const openCodeId = ProviderInstanceId.make("opencode_default"); + + const codexDriverKind = ProviderDriverKind.make("codex"); + const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); + const cursorDriverKind = ProviderDriverKind.make("cursor"); + const grokDriverKind = ProviderDriverKind.make("grok"); + const openCodeDriverKind = ProviderDriverKind.make("opencode"); + + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: codexDriverKind, + displayName: "Codex", + enabled: false, + config: makeCodexConfig({ homePath: "/home/julius/.codex" }), + }, + [claudeId]: { + driver: claudeDriverKind, + displayName: "Claude", + enabled: false, + config: makeClaudeConfig({ + homePath: "/home/julius/.claude-work", + launchArgs: "--verbose", + }), + }, + [cursorId]: { + driver: cursorDriverKind, + displayName: "Cursor", + enabled: false, + config: makeCursorConfig({}), + }, + [grokId]: { + driver: grokDriverKind, + displayName: "Grok", + enabled: false, + config: makeGrokConfig({}), + }, + [openCodeId]: { + driver: openCodeDriverKind, + displayName: "OpenCode", + enabled: false, + config: makeOpenCodeConfig({}), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry< + CodexDriverEnv | ClaudeDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv + >({ + drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + configMap, + }); + + // Every configured instance must materialize — none downgraded to a + // shadow snapshot, because every driver in the map is registered. + const unavailable = yield* registry.listUnavailable; + expect(unavailable).toEqual([]); + + const instances = yield* registry.listInstances; + expect(instances).toHaveLength(5); + expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( + [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + ); + + // Instance lookup by id resolves each instance to its own bundle — + // this is how rest-of-server routes turn/session calls in the new + // model. Each driver's bundle carries its advertised `driverKind`. + const codex = yield* registry.getInstance(codexId); + const claude = yield* registry.getInstance(claudeId); + const cursor = yield* registry.getInstance(cursorId); + const grok = yield* registry.getInstance(grokId); + const openCode = yield* registry.getInstance(openCodeId); + expect(codex?.driverKind).toBe(codexDriverKind); + expect(claude?.driverKind).toBe(claudeDriverKind); + expect(cursor?.driverKind).toBe(cursorDriverKind); + expect(grok?.driverKind).toBe(grokDriverKind); + expect(openCode?.driverKind).toBe(openCodeDriverKind); + expect(codex?.displayName).toBe("Codex"); + expect(claude?.displayName).toBe("Claude"); + expect(cursor?.displayName).toBe("Cursor"); + expect(grok?.displayName).toBe("Grok"); + expect(openCode?.displayName).toBe("OpenCode"); + + // Every instance owns its own set of closures — no sharing across + // drivers. `orchestrationAdapter` / `textGeneration` / `snapshot` are all + // distinct references even when two instances happen to share a + // trait (e.g. Cursor + others all use a stub-or-real + // `textGeneration`; they must still be different object values). + const adapters = [ + codex!.orchestrationAdapter, + claude!.orchestrationAdapter, + cursor!.orchestrationAdapter, + grok!.orchestrationAdapter, + openCode!.orchestrationAdapter, + ]; + expect(new Set(adapters).size).toBe(adapters.length); + const textGenerations = [ + codex!.textGeneration, + claude!.textGeneration, + cursor!.textGeneration, + grok!.textGeneration, + openCode!.textGeneration, + ]; + expect(new Set(textGenerations).size).toBe(textGenerations.length); + const snapshots = [ + codex!.snapshot, + claude!.snapshot, + cursor!.snapshot, + grok!.snapshot, + openCode!.snapshot, + ]; + expect(new Set(snapshots).size).toBe(snapshots.length); + + // Snapshots identify themselves by `instanceId` + `driver` so + // downstream aggregation in `ProviderRegistry` can tell instances + // apart even when two share a driver. With `enabled: false`, the + // check short-circuits and we get a disabled/pending snapshot back + // — that's enough signal to validate the stamping wrapper without + // spawning real binaries. + const codexSnapshot = yield* codex!.snapshot.getSnapshot; + expect(codexSnapshot.instanceId).toBe(codexId); + expect(codexSnapshot.driver).toBe(codexDriverKind); + expect(codexSnapshot.enabled).toBe(false); + expect(codexSnapshot.continuation?.groupKey).toBe( + `codex:home:${(yield* Path.Path).resolve("/home/julius/.codex")}`, + ); + + const claudeSnapshot = yield* claude!.snapshot.getSnapshot; + expect(claudeSnapshot.instanceId).toBe(claudeId); + expect(claudeSnapshot.driver).toBe(claudeDriverKind); + expect(claudeSnapshot.enabled).toBe(false); + expect(claudeSnapshot.continuation?.groupKey).toBe( + `claude:home:${(yield* Path.Path).resolve("/home/julius/.claude-work")}`, + ); + + const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; + expect(cursorSnapshot.instanceId).toBe(cursorId); + expect(cursorSnapshot.driver).toBe(cursorDriverKind); + expect(cursorSnapshot.enabled).toBe(false); + expect(cursorSnapshot.continuation?.groupKey).toBe( + `${cursorDriverKind}:instance:${cursorId}`, + ); + + const grokSnapshot = yield* grok!.snapshot.getSnapshot; + expect(grokSnapshot.instanceId).toBe(grokId); + expect(grokSnapshot.driver).toBe(grokDriverKind); + expect(grokSnapshot.enabled).toBe(false); + expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); + + const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; + expect(openCodeSnapshot.instanceId).toBe(openCodeId); + expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); + expect(openCodeSnapshot.enabled).toBe(false); + expect(openCodeSnapshot.continuation?.groupKey).toBe( + `${openCodeDriverKind}:instance:${openCodeId}`, + ); + }).pipe(Effect.provide(layerTest)), + ); +}); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..eecd4fc5cbecdedb2697285b99bfcfcd61fa04a3 --- /dev/null +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -0,0 +1,508 @@ +/** + * 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. + * + * The live implementation (`makeProviderInstanceRegistry`, `layer`) also + * provides its sibling mutator. + * + * Materializes every entry in a `ProviderInstanceConfigMap`: + * + * - When the entry's `driver` matches a registered driver, the registry + * decodes the opaque `config` envelope through `driver.configSchema` + * and calls `driver.create()` inside a fresh child scope. The + * resulting `ProviderInstance` is stored keyed by instance id, + * alongside its scope so the entry can be torn down independently. + * - When the entry's `driver` is unknown to this build (fork, rollback, + * in-flight PR branch), the registry emits an `"unavailable"` shadow + * `ServerProvider` snapshot instead of failing. This is what makes + * downgrades and fork-hopping safe per the + * `forward/backward compatibility invariant` in + * `packages/contracts/src/providerInstance.ts`. + * - When the entry's config fails schema decode, the registry logs and + * emits a shadow snapshot with the schema detail — same bucket as an + * unknown driver. + * + * Unlike the pre-Slice-D layer, the registry now holds mutable state + * (`Ref`s + `PubSub`) and exposes an internal mutator + * (`ProviderInstanceRegistryMutator`) whose `reconcile` method diffs a + * fresh config map against the live state, tearing down removed instances + * and building new ones without disturbing unaffected instances. + * + * Every live instance runs inside its own child `Scope`. The registry's + * own scope owns all child scopes via finalizers, so closing the registry + * tears every instance down in reverse order; closing a single instance + * (via `reconcile` removing it) leaves the rest untouched. + * + * @module provider/ProviderInstanceRegistry + */ +import { + providerInstanceConfigEnabledFlag, + ProviderInstanceId, + type ProviderInstanceConfig, + type ProviderInstanceConfigMap, + type ProviderDriverKind, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; +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 Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import { buildUnavailableProviderSnapshot } from "./unavailableProviderSnapshot.ts"; +import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; +import type { AnyProviderDriver, ProviderInstance } from "./ProviderDriver.ts"; + +export class ProviderInstanceRegistry extends Context.Service< + ProviderInstanceRegistry, + { + /** + * 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>; + } +>()("t3/provider/ProviderInstanceRegistry") {} + +/** + * Live registry entry: the materialized `ProviderInstance` + the fresh + * child scope its `create` effect ran in + the original `entry` envelope + * so `reconcile` can cheaply detect "no-op" updates. + */ +interface LiveEntry { + readonly instance: ProviderInstance; + readonly scope: Scope.Closeable; + readonly entry: ProviderInstanceConfig; +} + +/** + * Internal state shared between the public registry service and the + * mutator service. Both services are thin shells around these refs. + */ +interface RegistryState { + readonly entries: Ref.Ref>; + readonly unavailable: Ref.Ref>; + readonly changes: PubSub.PubSub; +} + +/** + * Structural equality on `ProviderInstanceConfig` envelopes. Used by + * `reconcile` to skip rebuilds when settings arrive unchanged. Config + * payloads are opaque `unknown` at the envelope layer; `Equal.equals` + * falls back to structural equality for plain records, which matches how + * the schema decode output is constructed. + */ +const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => + Equal.equals(a, b); + +/** + * Resolve an entry's enabled state. An explicit false on either the + * envelope or the raw config blob wins (most restrictive) — old settings + * files can carry both flags with conflicting values, and a user's disable + * must never be silently undone. Otherwise the envelope flag wins, then the + * decoded config's flag (which carries the driver schema's default for + * built-ins and forks alike), then enabled by default. + */ +const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { + const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config); + if (entry.enabled === false || rawConfigEnabled === false) { + return false; + } + return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true; +}; + +/** + * Build one live entry from a raw config envelope. Returns either a + * `LiveEntry` plus undefined unavailable shadow, or a shadow snapshot and + * undefined entry — callers dispatch to the appropriate Ref bucket. + */ +const buildEntry = (input: { + readonly driversById: ReadonlyMap>; + readonly parentScope: Scope.Scope; + readonly instanceId: ProviderInstanceId; + readonly rawInstanceId: string; + readonly entry: ProviderInstanceConfig; +}): Effect.Effect< + | { readonly kind: "live"; readonly live: LiveEntry } + | { readonly kind: "unavailable"; readonly snapshot: ServerProvider }, + never, + R +> => + Effect.gen(function* () { + const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; + const driver = driversById.get(entry.driver); + if (!driver) { + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Driver '${entry.driver}' is not registered in this build.`, + }), + }; + } + + 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); + yield* Effect.logError("Failed to decode provider instance config", { + instanceId: rawInstanceId, + driver: entry.driver, + detail, + }); + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, + }), + }; + } + + const typedConfig = decodeResult.success; + const childScope = yield* Scope.make(); + // Attach the child scope to the registry's parent scope: if the + // registry scope closes, each surviving instance's child scope is + // closed through this finalizer. `reconcile` manually closes the + // child scope on remove/replace; subsequent close via the parent's + // finalizer is a no-op because `Scope.close` is idempotent. + yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); + + const createResult = yield* driver + .create({ + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + environment: entry.environment ?? [], + enabled: resolveEntryEnabled(entry, typedConfig), + config: typedConfig, + }) + .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); + if (createResult._tag === "Failure") { + yield* Effect.logError("Failed to create provider instance", { + instanceId: rawInstanceId, + driver: entry.driver, + detail: createResult.failure.detail, + }); + yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, + }), + }; + } + + return { + kind: "live" as const, + live: { + instance: createResult.success, + scope: childScope, + entry, + }, + }; + }); + +/** + * Reconcile-only implementation of the mutator. Exposed to the hydration + * layer; never called directly by the rest of the server. + */ +const makeReconcile = (input: { + readonly state: RegistryState; + readonly driversById: ReadonlyMap>; + readonly parentScope: Scope.Scope; +}): ((configMap: ProviderInstanceConfigMap) => Effect.Effect) => { + const { state, driversById, parentScope } = input; + return (configMap: ProviderInstanceConfigMap) => + Effect.gen(function* () { + const previousEntries = yield* Ref.get(state.entries); + const previousUnavailable = yield* Ref.get(state.unavailable); + const nextRaw = Object.entries(configMap); + const nextKeys = new Set( + nextRaw.map(([raw]) => ProviderInstanceId.make(raw)), + ); + + // 1. Close scopes for instances that disappeared or whose config + // changed. Do this BEFORE creating replacements so ids map 1-to-1 + // to live scopes at all times. + const removedIds: Array = []; + const replacedIds = new Set(); + for (const [instanceId, live] of previousEntries) { + if (!nextKeys.has(instanceId)) { + removedIds.push(instanceId); + continue; + } + const nextEntry = configMap[instanceId]; + if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) { + replacedIds.add(instanceId); + } + } + for (const id of [...removedIds, ...replacedIds]) { + const live = previousEntries.get(id); + if (live) { + yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore); + } + } + + // 2. Build additions and replacements. Walk `nextRaw` so the final + // entry order follows settings-author order. + const builtEntries = new Map(); + const builtUnavailable = new Map(); + let orderChanged = false; + const previousOrder = [...previousEntries.keys()]; + const nextOrder: Array = []; + + for (const [rawInstanceId, entry] of nextRaw) { + const instanceId = ProviderInstanceId.make(rawInstanceId); + nextOrder.push(instanceId); + + const existing = previousEntries.get(instanceId); + if (existing !== undefined && !replacedIds.has(instanceId)) { + // No-op update: keep the existing live entry and scope. + builtEntries.set(instanceId, existing); + continue; + } + + const result = yield* buildEntry({ + driversById, + parentScope, + instanceId, + rawInstanceId, + entry, + }); + if (result.kind === "live") { + builtEntries.set(instanceId, result.live); + } else { + builtUnavailable.set(instanceId, result.snapshot); + } + } + + if (previousOrder.length === nextOrder.length) { + for (let i = 0; i < previousOrder.length; i++) { + if (previousOrder[i] !== nextOrder[i]) { + orderChanged = true; + break; + } + } + } else { + orderChanged = true; + } + + const entriesChanged = + orderChanged || + removedIds.length > 0 || + replacedIds.size > 0 || + builtEntries.size !== previousEntries.size; + const unavailableChanged = + builtUnavailable.size !== previousUnavailable.size || + [...builtUnavailable].some(([id, snapshot]) => { + const prev = previousUnavailable.get(id); + return prev === undefined || !Equal.equals(prev, snapshot); + }) || + [...previousUnavailable].some(([id]) => !builtUnavailable.has(id)); + + yield* Ref.set(state.entries, builtEntries); + yield* Ref.set(state.unavailable, builtUnavailable); + + if (entriesChanged || unavailableChanged) { + yield* PubSub.publish(state.changes, undefined); + } + }); +}; + +/** + * Build the registry's runtime state from a concrete configMap. Returns a + * record containing: + * + * - `registry`: the read-only `ProviderInstanceRegistry["Service"]` to expose + * under `ProviderInstanceRegistry`. + * - `mutator`: the `ProviderInstanceRegistryMutatorShape` to expose + * under `ProviderInstanceRegistryMutator`. + * - `reconcile`: the raw reconcile function, provided for convenience so + * boot-time layers can hydrate an initial map before publishing the + * services. + * + * The scope that this effect runs in owns every per-instance child scope + * created during `reconcile`. Closing that scope closes every live + * instance. + */ +export const makeProviderInstanceRegistry = (input: { + readonly drivers: ReadonlyArray>; + readonly configMap: ProviderInstanceConfigMap; +}): Effect.Effect< + { + readonly registry: ProviderInstanceRegistry["Service"]; + readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape; + }, + never, + R | Scope.Scope +> => + Effect.gen(function* () { + const driversById = new Map>( + input.drivers.map((driver) => [driver.driverKind, driver]), + ); + + // Capture the enclosing scope so per-instance child scopes can be + // attached to it at `reconcile` time. Without this, `reconcile` + // called later (e.g. from the hydration layer) would attach child + // scopes to the *caller's* scope instead of the registry's. + const parentScope = yield* Scope.Scope; + + // Capture the driver R context at construction time so `reconcile` + // can be invoked later without re-providing driver dependencies. + // The service tag's declared `reconcile: Effect` hides R from + // consumers — we materialize that here. + const driverContext = yield* Effect.context(); + + const entries = yield* Ref.make>(new Map()); + const unavailable = yield* Ref.make>(new Map()); + const changes = yield* PubSub.unbounded(); + yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); + + const state: RegistryState = { entries, unavailable, changes }; + const reconcileWithR = makeReconcile({ state, driversById, parentScope }); + const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["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: ProviderInstanceRegistry["Service"] = { + getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), + listInstances: Ref.get(entries).pipe( + Effect.map( + (map) => + Array.from(map.values(), (live) => live.instance) as ReadonlyArray, + ), + ), + listUnavailable: Ref.get(unavailable).pipe( + Effect.map((map) => Array.from(map.values()) as ReadonlyArray), + ), + // Getters: each read constructs a fresh Stream / Effect descriptor + // so multiple consumers don't share a single already-started + // Channel or subscription. Matches the pattern `ProviderRegistry` + // uses for its own `streamChanges`. + get streamChanges() { + return Stream.fromPubSub(changes); + }, + // Synchronous subscribe — callers that need to consume changes + // from a forked fibre must acquire the subscription in their own + // fibre first (via `yield* registry.subscribeChanges`) and only + // then fork a consumer loop on `Stream.fromSubscription(...)` / + // `PubSub.take(...)`. See the shape docs for the race this avoids. + get subscribeChanges() { + return PubSub.subscribe(changes); + }, + }; + + const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = { + reconcile, + }; + + return { registry, mutator }; + }); + +/** + * Layer variant that also exposes the mutator tag. Consumed by + * `ProviderInstanceRegistryHydrationLive` to reconcile on settings + * changes. Tests that exercise the mutator directly can pair this Layer + * with a test-local `ServerSettingsService`. + */ +export const layer = (input: { + readonly drivers: ReadonlyArray>; + readonly configMap: ProviderInstanceConfigMap; +}): Layer.Layer< + ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, + never, + R +> => + Layer.effectContext( + makeProviderInstanceRegistry(input).pipe( + Effect.map(({ registry, mutator }) => + Context.make(ProviderInstanceRegistry, registry).pipe( + Context.add(ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, mutator), + ), + ), + ), + ) as Layer.Layer< + ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, + never, + R + >; diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts new file mode 100644 index 0000000000000000000000000000000000000000..922eb48175d3b7dac99d216f57e1316f77c219b8 --- /dev/null +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -0,0 +1,192 @@ +/** + * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` + * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. + * + * The server still reads two shapes: + * + * 1. `settings.providerInstances` — the new driver-agnostic map the + * registry expects. Keyed by `ProviderInstanceId`, values are + * `ProviderInstanceConfig` envelopes. + * 2. `settings.providers.` — the legacy single-instance-per-driver + * fields (`providers.codex`, `providers.claudeAgent`, …). These are + * the source of truth for every deployment that hasn't been migrated + * yet to an explicit `providerInstances` entry. + * + * This module bridges (2) into (1) and wires the resulting map into a + * mutable registry. For every built-in driver whose id is not already + * present in `providerInstances` (keyed on + * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a + * routing slug), we synthesize an envelope from the legacy field. The + * registry decodes both flavours through the same `configSchema` and ends + * up with one uniform `ProviderInstance` per entry. + * + * Explicit `providerInstances` entries always win — users can already + * override the legacy `providers.` blob by authoring a + * `providerInstances.codex` entry with a matching driver, and we don't + * want the synthesized envelope to silently stomp their config. + * + * Hot-reload + * ---------- + * On layer build we: + * 1. Read the current `ServerSettings` once and use it to seed the + * registry's initial state via `ProviderInstanceRegistry.layer`. + * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that + * acquires `ServerSettingsService.subscribeChanges` and calls + * `ProviderInstanceRegistryMutator.reconcile` on every emission. + * + * Failures inside the watcher are logged and swallowed so a single bad + * settings emission cannot kill the registry. Unknown drivers and invalid + * configs already round-trip through the registry's own "unavailable" + * shadow bucket. + * + * @module provider/ProviderInstanceRegistryHydration + */ +import { + defaultInstanceIdForDriver, + type ProviderInstanceConfig, + type ProviderInstanceConfigMap, + ServerSettings, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import * as Settings from "../serverSettings.ts"; +import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "./builtInDrivers.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; +import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; +import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts"; +import * as AcpRegistryCatalog from "./AcpRegistryCatalog.ts"; + +type ProviderInstanceRegistryHydrationEnv = + | Exclude< + BuiltInDriversEnv, + | ProviderOrchestrationAdapterInfrastructure.ProviderOrchestrationAdapterInfrastructure + | AcpRegistrySupport.AcpRegistryCatalog + > + | Settings.ServerSettingsService; + +/** + * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. + * + * Strategy: + * 1. Copy all explicit `settings.providerInstances` entries verbatim. + * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key + * is *not* already in the explicit map, synthesize an entry from the + * matching legacy `settings.providers.` blob. + * + * The returned map is the input the registry consumes; pure & exported + * separately so the hydration logic can be exercised by unit tests + * without layering. + */ +export const deriveProviderInstanceConfigMap = ( + settings: ServerSettings, +): ProviderInstanceConfigMap => { + const merged: Record = { ...settings.providerInstances }; + + for (const driver of BUILT_IN_DRIVERS) { + const instanceId = defaultInstanceIdForDriver(driver.driverKind); + if (instanceId in merged) { + // Explicit `providerInstances` entry for this slot — user-authored + // config always wins over the legacy mirror. + continue; + } + + // Only built-in drivers have a legacy mirror; the registry's + // `providers` struct is keyed on the same literal slug as + // `driverKind`. Access is dynamic (the driver kind is a branded string), + // but it's constrained to `keyof settings.providers` by the union of + // built-in driver kinds. + const legacyKey = driver.driverKind as keyof ServerSettings["providers"]; + const legacyConfig = settings.providers[legacyKey]; + if (legacyConfig === undefined) { + continue; + } + + merged[instanceId] = { + driver: driver.driverKind, + config: legacyConfig, + }; + } + + return merged as ProviderInstanceConfigMap; +}; + +/** + * Layer that consumes `ProviderInstanceRegistryMutator` and forks a + * settings-watcher fiber. The fiber's lifetime is tied to the enclosing + * layer scope (process lifetime in production), so it is interrupted on + * shutdown without leaking. + * + * Errors inside the watcher are logged and swallowed — the registry's own + * "unavailable" bucket already absorbs unknown drivers and invalid + * configs, so the only way the watcher could fail is a settings stream + * tear-down, which logs and exits cleanly. + */ +const layerSettingsWatcher = Layer.effectDiscard( + Effect.gen(function* () { + const mutator = yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator; + const serverSettings = yield* Settings.ServerSettingsService; + const settingsChanges = yield* serverSettings.subscribeChanges; + yield* settingsChanges.pipe( + Stream.runForEach((next) => + mutator + .reconcile(deriveProviderInstanceConfigMap(next)) + .pipe( + Effect.catchCause((cause) => + Effect.logError("ProviderInstanceRegistry reconcile failed", cause), + ), + ), + ), + Effect.forkScoped, + ); + }), +); + +/** + * Hydrate `ProviderInstanceRegistry` from `ServerSettings` and keep it in + * sync with subsequent `streamChanges` emissions. + * + * The Layer's two halves: + * - `ProviderInstanceRegistry.layer` produces the registry + + * mutator from the initial config map. Its scope owns every + * per-instance child scope created during reconcile. + * - `SettingsWatcherLive` consumes the mutator, acquires its settings + * subscription before forking, and runs a daemon fiber in the same scope. + * + * Composing via `Layer.provideMerge` makes the watcher's deps available + * from the mutable layer while still surfacing the registry as an output. + * The mutator tag is technically also exposed; only this module imports + * it, so the visibility leak is harmless in practice. + */ +export const layer: Layer.Layer< + ProviderInstanceRegistry.ProviderInstanceRegistry, + never, + ProviderInstanceRegistryHydrationEnv +> = Layer.unwrap( + Effect.gen(function* () { + const serverSettings = yield* Settings.ServerSettingsService; + const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( + Effect.orElseSucceed(() => undefined), + ); + const initialConfigMap = + initialSettings === undefined + ? ({} as ProviderInstanceConfigMap) + : deriveProviderInstanceConfigMap(initialSettings); + + const layerMutable = ProviderInstanceRegistry.layer({ + drivers: BUILT_IN_DRIVERS, + configMap: initialConfigMap, + }).pipe( + Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer), + Layer.provide(AcpRegistryCatalog.layer), + ); + + return layerSettingsWatcher.pipe(Layer.provideMerge(layerMutable)); + }), +) as Layer.Layer< + ProviderInstanceRegistry.ProviderInstanceRegistry, + never, + ProviderInstanceRegistryHydrationEnv +>; diff --git a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts new file mode 100644 index 0000000000000000000000000000000000000000..b878036f4c86590975b481c7533d8bc689c6b9a3 --- /dev/null +++ b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts @@ -0,0 +1,52 @@ +/** + * 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/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/ProviderInstanceRegistryMutator") {} diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts new file mode 100644 index 0000000000000000000000000000000000000000..204ef10a2721a0232b256607d1fa70b81dda48b2 --- /dev/null +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -0,0 +1,27 @@ +import * as Layer from "effect/Layer"; + +import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as CursorAgentSdk from "../orchestration-v2/Adapters/CursorAgentSdk.ts"; +import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; +import * as ProviderContinuationRequests from "../orchestration-v2/ProviderContinuationRequests.ts"; + +export type ProviderOrchestrationAdapterInfrastructure = + | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner + | CodexAdapterV2.CodexAppServerClientFactory + | CursorAgentSdk.CursorAgentSdkRunner + | IdAllocator.IdAllocatorV2; + +/** + * Infrastructure shared by the V2 adapters materialized inside provider + * instances. `providerContinuationRequestsLayer` must be the same layer + * reference the orchestration runtime provides to its continuation worker so + * Effect layer memoization yields one shared queue. + */ +export const layer = Layer.mergeAll( + ClaudeAdapterV2.layerQueryRunner, + CodexAdapterV2.layerAppServerClientFactory, + CursorAgentSdk.layer, + IdAllocator.layer, + ProviderContinuationRequests.layer, +); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf3e9906b16ce30ae400bfbb76d65201971c7243 --- /dev/null +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -0,0 +1,3376 @@ +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as CodexInstallation from "./CodexInstallation.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, it, assert } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +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 Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as CodexErrors from "effect-codex-app-server/errors"; +import { + EnvironmentId, + ClaudeSettings, + CodexSettings, + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + ServerSettings, + type ServerProvider, + type ServerProviderSlashCommand, + type ServerSettings as ContractServerSettings, +} from "@t3tools/contracts"; +import * as PlatformError from "effect/PlatformError"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { ChildProcessSpawner } from "effect/process"; +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 BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as AntigravityInstallation from "./AntigravityInstallation.ts"; +import * as ModelManifest from "./ModelManifest.ts"; +import { applyProviderCompatibility } from "./providerCompatibility.ts"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; +import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; +import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerSettingsModule from "../serverSettings.ts"; +import { + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "./providerStatusCache.ts"; +import { COMPACT_SLASH_COMMAND } from "./providerSnapshot.ts"; +import type { ProviderInstance, ProviderWorkspaceSnapshot } from "./ProviderDriver.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +const decodeServerSettings = Schema.decodeSync(ServerSettings); +const encodeServerSettings = Schema.encodeSync(ServerSettings); +const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); + +const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); +const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); +const decodeCodexSettings = Schema.decodeSync(CodexSettings); +const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ + enabled: false, +}); + +process.env.T3CODE_CURSOR_ENABLED = "1"; + +// ── Test helpers ──────────────────────────────────────────────────── + +const encoder = new TextEncoder(); +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const withBundledCompatibility = (snapshot: ServerProvider) => + applyProviderCompatibility( + snapshot, + undefined, + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + ); + +// Provider metadata checks use a bundled manifest and stubbed HTTP. +const layerTestHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), + ), +).pipe(Layer.provideMerge(ModelManifest.layerTest)); + +const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + reportClientActivity: () => Effect.void, + removeRpcClient: () => Effect.void, + reportHostPowerState: () => Effect.void, + snapshot: Effect.succeed({ + hostPower: { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: TEST_EPOCH, + }, + leases: [], + activeForegroundLeaseCount: 0, + activeScopeKeys: [], + shouldRunOpportunisticWork: true, + updatedAt: TEST_EPOCH, + }), + streamChanges: Stream.empty, + hasDemand: () => Effect.succeed(true), + shouldRunScopeWork: () => Effect.succeed(true), + shouldRunOpportunisticWork: Effect.succeed(true), +}); + +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string) { + return { + id, + label, + type: "boolean" as const, + }; +} + +type TestClaudeCapabilities = { + readonly email: string | undefined; + readonly subscriptionType: string | undefined; + readonly tokenSource: string | undefined; + readonly apiProvider: string | undefined; + readonly slashCommands: ReadonlyArray; +}; + +function claudeCapabilities(overrides: Partial = {}) { + return () => + Effect.succeed({ + email: undefined, + subscriptionType: undefined, + tokenSource: undefined, + apiProvider: undefined, + slashCommands: [], + ...overrides, + }); +} + +const noClaudeCapabilities = () => + Effect.sync(() => undefined as TestClaudeCapabilities | undefined); + +function mockHandle(result: { stdout: string; stderr: string; code: number }) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout)), + stderr: Stream.make(encoder.encode(result.stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function layerMockSpawner( + handler: (args: ReadonlyArray) => { + stdout: string; + stderr: string; + code: number; + }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler(cmd.args))); + }), + ); +} + +function recordingMockSpawnerLayer( + handler: (args: ReadonlyArray) => { + stdout: string; + stderr: string; + code: number; + }, +) { + const commands: Array<{ + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv | undefined; + }> = []; + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { + args: ReadonlyArray; + options?: { + readonly env?: NodeJS.ProcessEnv; + }; + }; + commands.push({ args: cmd.args, env: cmd.options?.env }); + return Effect.succeed(mockHandle(handler(cmd.args))); + }), + ); + return { layer, commands }; +} + +function layerMockCommandSpawner( + handler: ( + command: string, + args: ReadonlyArray, + ) => { stdout: string; stderr: string; code: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { + command: string; + args: ReadonlyArray; + }; + return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); + }), + ); +} + +function layerFailingSpawner(description: string) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description, + }), + ), + ), + ); +} + +function layerHangingScopedSpawner(killCalls: Ref.Ref) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Ref.update(killCalls, (current) => current + 1), + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.never, + stderr: Stream.never, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ), + ); +} + +const codexModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + { id: "low", label: "Low" }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + ], +}) satisfies NonNullable; + +function makeCodexProbeSnapshot( + input: Partial = {}, +): CodexAppServerProviderSnapshot { + return { + version: "1.0.0", + account: { + account: { + type: "chatgpt", + email: "test@example.com", + planType: "pro", + }, + requiresOpenaiAuth: false, + }, + models: [ + { + slug: "gpt-live-codex", + name: "GPT Live Codex", + isCustom: false, + capabilities: codexModelCapabilities, + }, + ], + skills: [], + ...input, + }; +} + +function makeMutableServerSettingsService( + initial: ContractServerSettings = DEFAULT_SERVER_SETTINGS, +) { + return Effect.gen(function* () { + const settingsRef = yield* Ref.make(initial); + const changes = yield* PubSub.unbounded(); + + return { + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(settingsRef), + updateSettings: (patch) => + Effect.gen(function* () { + const current = yield* Ref.get(settingsRef); + const next = applyServerSettingsPatch(current, patch); + encodeServerSettings(next); + yield* Ref.set(settingsRef, next); + yield* PubSub.publish(changes, next); + return next; + }), + updateProviderInstance: (mutation, patch = {}) => + Effect.gen(function* () { + const current = yield* Ref.get(settingsRef); + const next = ServerSettingsModule.applyProviderInstanceMutation( + applyServerSettingsPatch(current, patch), + mutation, + ); + encodeServerSettings(next); + yield* Ref.set(settingsRef, next); + yield* PubSub.publish(changes, next); + return next; + }), + withSettingsSnapshot: (use) => Ref.get(settingsRef).pipe(Effect.flatMap(use)), + get streamChanges() { + return Stream.fromPubSub(changes); + }, + get subscribeChanges() { + return PubSub.subscribe(changes).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ); + }, + } satisfies ServerSettingsModule.ServerSettingsService["Service"]; + }); +} + +// The registry writes the status cache and only then publishes the change, so +// a subscriber that sees `checkedAt` on the stream knows the file is on disk. +// Subscribed before the publish that triggers it; a spin on the file would +// race the write and lose on a slow host. +const awaitPersistedProvider = ( + registry: ProviderRegistry.ProviderRegistry["Service"], + checkedAt: string, +) => + registry.streamChanges.pipe( + Stream.filter((providers) => providers.some((provider) => provider.checkedAt === checkedAt)), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + +const layerTestNodeServices = Layer.mergeAll( + NodeServices.layer, + Layer.mock(CodexInstallation.CodexInstallation)({ + managedDirectory: "unused-managed-installation", + }), + Layer.mock(ServerSecretStore.ServerSecretStore)({}), + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), + }), +); + +it.layer( + Layer.mergeAll(layerTestNodeServices, ServerSettingsModule.layerTest(), layerTestHttpClient), +)("ProviderRegistry", (it) => { + describe("checkCodexProviderStatus", () => { + it.effect("uses the app-server account and model list for provider status", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + skills: [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ], + }), + ), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, "1.0.0"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "chatgpt"); + assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); + assert.strictEqual(status.auth.email, "test@example.com"); + assert.deepStrictEqual(status.models, [ + { + slug: "gpt-live-codex", + name: "GPT Live Codex", + isCustom: false, + capabilities: codexModelCapabilities, + }, + ]); + assert.deepStrictEqual(status.skills, [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]); + assert.deepStrictEqual(status.slashCommands.slice(1), [ + { + name: "goal", + description: "Set a goal Codex keeps working toward until it is done", + input: { hint: "Objective, or pause, resume, clear" }, + }, + { + name: "feedback", + description: "Send this thread and Codex logs to OpenAI", + input: { hint: "Describe the issue (optional)" }, + }, + ]); + }), + ); + + it.effect("passes configured launch args to the Codex provider probe", () => + Effect.gen(function* () { + let observedLaunchArgs: string | undefined; + const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); + + const status = yield* checkCodexProviderStatus(settings, (input) => { + observedLaunchArgs = input.launchArgs; + return Effect.succeed(makeCodexProbeSnapshot()); + }); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); + }), + ); + + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: true, + }, + }), + ), + ); + + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }), + ); + + it.effect("returns ready with unknown auth when app-server does not require OpenAI auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: false, + }, + }), + ), + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "unknown"); + }), + ); + + it.effect("returns an api key label for codex api key auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }, + }), + ), + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.auth.label, "OpenAI API Key"); + }), + ); + + it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { type: "amazonBedrock" }, + requiresOpenaiAuth: false, + }, + }), + ), + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "amazonBedrock"); + assert.strictEqual(status.auth.label, "Amazon Bedrock"); + }), + ); + + it.effect.each([ + "codex", + "/Applications/Custom App.app/Contents/Resources/codex", + "C:\\Tools\\codex.exe", + ])("explains how to configure a Codex executable that cannot start: %s", (binaryPath) => + Effect.gen(function* () { + const settings = { ...defaultCodexSettings, binaryPath }; + const status = yield* checkCodexProviderStatus(settings, (input) => { + assert.strictEqual(input.binaryPath, binaryPath); + return Effect.fail( + new CodexErrors.CodexAppServerSpawnError({ + command: `${binaryPath} app-server`, + cause: new Error("spawn ENOENT"), + }), + ); + }); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.auth.status, "unknown"); + assert.include(status.message, binaryPath); + assert.include(status.message, "Settings → Providers → Codex → Binary path on the server"); + assert.strictEqual( + status.message?.includes("Installing ChatGPT or Codex desktop"), + binaryPath === "codex", + ); + assert.strictEqual(settings.binaryPath, binaryPath); + }), + ); + + it.effect("closes the app-server probe scope when provider status times out", () => + Effect.gen(function* () { + const killCalls = yield* Ref.make(0); + const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( + Effect.provide(layerHangingScopedSpawner(killCalls)), + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust("11 seconds"); + yield* Effect.yieldNow; + + const status = yield* Fiber.join(statusFiber); + assert.strictEqual(status.status, "error"); + assert.strictEqual( + status.message, + "Timed out while checking Codex app-server provider status.", + ); + assert.strictEqual(yield* Ref.get(killCalls), 1); + }), + ); + }); + + describe("ProviderRegistry.layer", () => { + it("stores workspace skills and commands without changing machine metadata", () => { + const provider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + } satisfies ServerProvider; + const scopedSnapshot = { + ...provider, + checkedAt: "2026-03-25T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }], + } satisfies ServerProvider; + + const result = ProviderRegistry.upsertProviderWorkspaceSnapshot( + provider, + "/project", + scopedSnapshot, + ); + + assert.deepStrictEqual(result.slashCommands, provider.slashCommands); + assert.deepStrictEqual(result.skills, provider.skills); + assert.deepStrictEqual(result.workspaceSnapshots, [ + { + cwd: "/project", + checkedAt: scopedSnapshot.checkedAt, + slashCommands: scopedSnapshot.slashCommands, + skills: scopedSnapshot.skills, + }, + ]); + + const pendingSnapshot = { + ...scopedSnapshot, + slashCommands: [COMPACT_SLASH_COMMAND], + slashCommandsPending: true, + } satisfies ProviderWorkspaceSnapshot; + const partial = ProviderRegistry.upsertProviderWorkspaceSnapshot( + result, + "/project", + pendingSnapshot, + ); + assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.slashCommands, [{ name: "project" }]); + assert.strictEqual(partial.workspaceSnapshots?.[0]?.slashCommandsPending, true); + const otherProject = ProviderRegistry.upsertProviderWorkspaceSnapshot( + partial, + "/other-project", + pendingSnapshot, + ); + assert.deepStrictEqual(otherProject.workspaceSnapshots?.[1]?.slashCommands, [ + COMPACT_SLASH_COMMAND, + ]); + const recovered = ProviderRegistry.upsertProviderWorkspaceSnapshot(partial, "/project", { + ...scopedSnapshot, + slashCommands: [COMPACT_SLASH_COMMAND, { name: "replacement" }], + }); + assert.deepStrictEqual(recovered.workspaceSnapshots?.[0]?.slashCommands, [ + COMPACT_SLASH_COMMAND, + { name: "replacement" }, + ]); + assert.strictEqual(recovered.workspaceSnapshots?.[0]?.slashCommandsPending, undefined); + }); + + it("preserves previously discovered provider models when a refresh returns none", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + slashCommands: [], + skills: [], + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [...previousProvider.models], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, + [], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).skills, + [], + ); + }); + + it("drops custom models the refreshed snapshot no longer carries", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2.1.0", + models: [ + { + slug: "claude-sonnet-4-6", + name: "Sonnet 4.6", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-custom", + name: "removed-custom", + isCustom: true, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [previousProvider.models[0]], + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [...refreshedProvider.models], + ); + }); + + it("drops stale ACP Registry models missing from a completed discovery probe", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("acpRegistry_codex"), + driver: ProviderDriverKind.make("acpRegistry"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-08-13T00:00:00.000Z", + version: "1.2.0", + models: [ + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + { + slug: "gpt-5.6-sol[low]", + name: "GPT-5.6-Sol (low)", + isCustom: false, + capabilities: null, + }, + { slug: "default", name: "Default", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-08-13T00:01:00.000Z", + models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [...refreshedProvider.models], + ); + }); + + it("retains ACP Registry models while discovery has not completed", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("acpRegistry_codex"), + driver: ProviderDriverKind.make("acpRegistry"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-08-13T00:00:00.000Z", + version: "1.2.0", + models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const checkingProvider = { + ...previousProvider, + checkedAt: "2026-08-13T00:01:00.000Z", + auth: { status: "unknown" }, + models: [{ slug: "default", name: "Default", isCustom: false, capabilities: null }], + } satisfies ServerProvider; + const failedProbeProvider = { + ...checkingProvider, + status: "warning", + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, checkingProvider).models, + [ + { slug: "default", name: "Default", isCustom: false, capabilities: null }, + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + ], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, failedProbeProvider).models, + [ + { slug: "default", name: "Default", isCustom: false, capabilities: null }, + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + ], + ); + }); + + it("drops stale OpenCode models missing from a successful refresh", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [...refreshedProvider.models], + ); + }); + + it("retains stale OpenCode models when a refresh fails", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [...previousProvider.models], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, + previousProvider.slashCommands, + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).skills, + previousProvider.skills, + ); + }); + + it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], + } as const satisfies ServerProvider; + const pendingProvider = { + ...previousProvider, + status: "warning", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message: "OpenCode provider status has not been checked in this session yet.", + } satisfies ServerProvider; + const loggedOutProvider = { + ...previousProvider, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + slashCommands: [], + skills: [], + message: "OpenCode is available, but it did not report any connected upstream providers.", + } satisfies ServerProvider; + const missingProvider = { + ...previousProvider, + status: "error", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:03:00.000Z", + version: null, + models: [], + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + } satisfies ServerProvider; + const authoritativeProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:04:00.000Z", + models: [previousProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:05:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, pendingProvider).models, + [...previousProvider.models], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).models, + [], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands, + [], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).skills, + [], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, missingProvider).models, + [], + ); + + const afterRemoval = ProviderRegistry.mergeProviderSnapshot( + previousProvider, + authoritativeProvider, + ); + const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRemoval, failedProvider); + + assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); + }); + + describe("Codex model inventories", () => { + const cachedProvider = { + instanceId: ProviderInstanceId.make("codex-personal"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-04T19:00:00.000Z", + version: "0.153.3", + models: ["vega-alpha", "joule-alpha", "kindle-alpha", "ultima-alpha", "solstice-alpha"].map( + (slug) => ({ slug, name: slug, isCustom: false, capabilities: null }), + ), + slashCommands: [], + skills: [], + } satisfies ServerProvider; + const customModel = { + slug: "custom-model", + name: "Custom model", + isCustom: true, + capabilities: null, + } as const; + const refreshedProvider = { + ...cachedProvider, + checkedAt: "2026-09-04T19:01:00.000Z", + models: [ + { slug: "gpt-6-astra", name: "GPT 6 Astra", isCustom: false, capabilities: null }, + cachedProvider.models[0]!, + customModel, + ], + } satisfies ServerProvider; + const pendingProvider = { + ...cachedProvider, + status: "warning", + installed: false, + auth: { status: "unknown" }, + models: [customModel], + } satisfies ServerProvider; + const failedProvider = { + ...pendingProvider, + checkedAt: "2026-09-04T19:02:00.000Z", + status: "error", + installed: true, + } satisfies ServerProvider; + + it("drops retired alpha models after discovery, including without OpenAI authentication", () => { + for (const authStatus of ["authenticated", "unknown"] as const) { + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(cachedProvider, { + ...refreshedProvider, + auth: { status: authStatus }, + }).models, + refreshedProvider.models, + ); + } + }); + + it("keeps discovered models during startup and failed probes without restoring removed custom models", () => { + for (const provider of [pendingProvider, failedProvider]) { + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot( + { + ...cachedProvider, + models: [...cachedProvider.models, { ...customModel, slug: "removed-custom" }], + }, + provider, + ).models, + [customModel, ...cachedProvider.models], + ); + } + }); + + it("clears discovered models after sign-out, disable, uninstall, or empty discovery", () => { + const emptyProvider = { ...refreshedProvider, models: [customModel] }; + const clearedProviders = [ + { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, + { ...emptyProvider, status: "disabled", enabled: false }, + { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, + emptyProvider, + { ...emptyProvider, models: [] }, + ] satisfies ReadonlyArray; + + for (const provider of clearedProviders) { + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(cachedProvider, provider).models, + provider.models, + ); + } + }); + + it.effect("persists removals across failed refreshes and registry restarts", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: cachedProvider.instanceId, + }); + yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); + const nextProvider = yield* Ref.make(refreshedProvider); + const instance = { + instanceId: cachedProvider.instanceId, + driverKind: cachedProvider.driver, + continuationIdentity: { + driverKind: cachedProvider.driver, + continuationKey: "codex:instance:codex-personal", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: cachedProvider.driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(pendingProvider), + refresh: Ref.get(nextProvider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (id) => + Effect.succeed(id === instance.instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const retainedModels = [ + customModel, + ...refreshedProvider.models.filter((model) => !model.isCustom), + ]; + + for (const restarted of [false, true]) { + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const expectedModels = restarted + ? retainedModels + : [customModel, ...cachedProvider.models]; + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, expectedModels); + + yield* registry.refreshInstance(instance.instanceId); + assert.deepStrictEqual( + (yield* readProviderStatusCache(filePath))?.models, + restarted ? retainedModels : refreshedProvider.models, + ); + + yield* Ref.set(nextProvider, failedProvider); + const afterFailure = yield* registry.refreshInstance(instance.instanceId); + assert.deepStrictEqual(afterFailure[0]?.models, retainedModels); + assert.deepStrictEqual( + (yield* readProviderStatusCache(filePath))?.models, + retainedModels, + ); + }).pipe( + Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerInstanceRegistry))), + Effect.scoped, + ); + } + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-codex-retired-model-cache-", + }).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), + ); + }); + + describe("Antigravity model inventories", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("antigravity-personal"), + driver: ProviderDriverKind.make("antigravity"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-02T00:00:00.000Z", + version: "0.1.3", + models: [ + { + slug: "gemini-3.1-pro-high", + name: "Gemini 3.1 Pro High", + isCustom: false, + capabilities: null, + }, + { + slug: "gemini-3-flash", + name: "Gemini 3 Flash", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + + it("removes unavailable models after a successful refresh", () => { + for (const status of ["ready", "warning"] as const) { + const refreshedProvider = { + ...previousProvider, + status, + checkedAt: "2026-09-02T00:01:00.000Z", + models: [previousProvider.models[1]], + } satisfies ServerProvider; + const afterRefresh = ProviderRegistry.mergeProviderSnapshot( + previousProvider, + refreshedProvider, + ); + + assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models); + + const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRefresh, { + ...refreshedProvider, + status: "error", + auth: { status: "unknown" }, + models: [], + }); + assert.deepStrictEqual(afterFailure.models, refreshedProvider.models); + } + }); + + it("keeps cached models during health checks and temporary failures", () => { + for (const installed of [false, true]) { + const pendingProvider = { + ...previousProvider, + status: "warning", + installed, + auth: { status: "unknown" }, + checkedAt: "2026-09-02T00:01:00.000Z", + version: installed ? previousProvider.version : null, + models: [], + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, pendingProvider).models, + previousProvider.models, + ); + } + + for (const authStatus of ["unknown", "authenticated"] as const) { + const failedProvider = { + ...previousProvider, + status: "error", + auth: { status: authStatus }, + checkedAt: "2026-09-02T00:02:00.000Z", + models: [], + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, failedProvider).models, + previousProvider.models, + ); + } + }); + + it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => { + const emptyProvider = { + ...previousProvider, + checkedAt: "2026-09-02T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + const clearedProviders = [ + { ...emptyProvider, status: "warning", auth: { status: "unauthenticated" } }, + { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, + { ...emptyProvider, status: "disabled", enabled: false }, + { ...emptyProvider, status: "error", enabled: false }, + { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, + emptyProvider, + ] satisfies ReadonlyArray; + + for (const provider of clearedProviders) { + const afterRemoval = ProviderRegistry.mergeProviderSnapshot(previousProvider, provider); + assert.deepStrictEqual(afterRemoval.models, []); + + const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRemoval, { + ...emptyProvider, + status: "error", + auth: { status: "unknown" }, + }); + assert.deepStrictEqual(afterFailure.models, []); + } + }); + }); + + describe("Antigravity saved account", () => { + const signedIn = { + instanceId: ProviderInstanceId.make("antigravity-personal"), + driver: ProviderDriverKind.make("antigravity"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "oauth-personal", label: "Google account" }, + checkedAt: "2026-09-05T00:00:00.000Z", + version: "agy_acp_server_1.1.1", + models: [ + { + slug: "gemini-3.7-flash-high", + name: "Gemini 3.7 Flash", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [{ name: "plan" }], + skills: [], + } as const satisfies ServerProvider; + const uncheckedMessage = + "Antigravity is installed. Google account access is not checked yet."; + const restartProbe = { + ...signedIn, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-09-05T00:01:00.000Z", + message: uncheckedMessage, + models: [], + } as const satisfies ServerProvider; + + it("keeps the saved Google account through restart health checks", () => { + const merged = ProviderRegistry.mergeProviderSnapshot(signedIn, restartProbe); + const { message: _uncheckedMessage, ...probeWithoutMessage } = restartProbe; + assert.deepStrictEqual(merged, { + ...probeWithoutMessage, + status: "ready", + auth: signedIn.auth, + models: signedIn.models, + }); + assert.equal("message" in merged, false); + // The next periodic probe reads the merged snapshot as its previous state. + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(merged, restartProbe), + merged, + ); + }); + + it("carries the account through the boot probe and a failed probe without hiding them", () => { + const booting = { + ...restartProbe, + installed: false, + version: null, + message: "Checking Antigravity availability.", + } satisfies ServerProvider; + assert.deepStrictEqual(ProviderRegistry.mergeProviderSnapshot(signedIn, booting), { + ...booting, + auth: signedIn.auth, + models: signedIn.models, + }); + + const failed = { + ...restartProbe, + status: "error", + message: "Antigravity did not respond to its local health check within 90 seconds.", + } satisfies ServerProvider; + assert.deepStrictEqual(ProviderRegistry.mergeProviderSnapshot(signedIn, failed), { + ...failed, + auth: signedIn.auth, + models: signedIn.models, + }); + }); + + it("does not invent an account after sign-out, disable, uninstall, or for other providers", () => { + const untouched = [ + { ...restartProbe, auth: { status: "unauthenticated" } }, + { ...restartProbe, status: "disabled", enabled: false }, + { ...restartProbe, status: "error", installed: false }, + { ...restartProbe, driver: ProviderDriverKind.make("codex") }, + // The instance was rebuilt with another sign-in method. + { ...restartProbe, auth: { status: "unknown", type: "gemini-api-key" } }, + ] satisfies ReadonlyArray; + for (const next of untouched) { + const merged = ProviderRegistry.mergeProviderSnapshot(signedIn, next); + assert.deepStrictEqual(merged.auth, next.auth); + assert.equal(merged.status, next.status); + assert.equal(merged.message, next.message); + } + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot( + { ...signedIn, auth: { status: "unknown" } }, + restartProbe, + ).auth, + { status: "unknown" }, + ); + assert.equal( + ProviderRegistry.mergeProviderSnapshot( + { ...signedIn, driver: ProviderDriverKind.make("codex") }, + restartProbe, + ).auth.status, + "unknown", + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(signedIn, { + ...restartProbe, + auth: { status: "unknown", type: "oauth-personal" }, + }).auth, + signedIn.auth, + ); + }); + }); + + it("fills missing capabilities from the previous provider snapshot", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [], + }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [...previousProvider.models], + ); + }); + + it.effect("does not run provider probes during layer construction", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const initialProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: null, + message: "Checking Codex provider status.", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshCalls = yield* Ref.make(0); + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(initialProvider), + refresh: Ref.update(refreshCalls, (count) => count + 1).pipe( + Effect.andThen(Effect.never), + ), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const layerInstanceRegistry = 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.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-background-refresh-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); + assert.strictEqual(yield* Ref.get(refreshCalls), 0); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const machineProvider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const scopedProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const pendingScopedProvider = { + ...scopedProvider, + status: "error", + installed: false, + slashCommands: [], + } as const satisfies ServerProvider; + const snapshotCalls = yield* Ref.make(0); + const scopedResult = yield* Ref.make({ + ...scopedProvider, + status: "error", + slashCommands: [COMPACT_SLASH_COMMAND], + slashCommandsPending: true, + }); + const cacheInvalidations = yield* Ref.make(0); + const scanGate = yield* Ref.make<{ + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + } | null>(null); + const returnPendingSnapshot = yield* Ref.make(true); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const makeInstance = ( + provider: ServerProvider, + snapshotForCwd: NonNullable, + ): ProviderInstance => ({ + instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + snapshotForCwd, + invalidateCaches: Ref.update(cacheInvalidations, (count) => count + 1), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }); + const firstInstance = makeInstance(machineProvider, () => + Effect.gen(function* () { + yield* Ref.update(snapshotCalls, (count) => count + 1); + if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + const result = yield* Ref.get(scopedResult); + const gate = yield* Ref.getAndSet(scanGate, null); + if (gate) { + yield* Deferred.succeed(gate.started, undefined); + yield* Deferred.await(gate.release); + } + return result; + }), + ); + const rebuiltProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:02:00.000Z", + status: "warning", + installed: false, + auth: { status: "unknown" }, + } satisfies ServerProvider; + const rebuiltInstance = makeInstance(rebuiltProvider, () => + Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), + ); + const registryChanges = yield* PubSub.unbounded(); + const instancesRef = yield* Ref.make>([firstInstance]); + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === requestedId), + ), + ), + listInstances: Ref.get(instancesRef), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(registryChanges), + subscribeChanges: PubSub.subscribe(registryChanges), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-workspace-snapshot-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); + yield* Ref.set(returnPendingSnapshot, false); + const workspaceUpdate = yield* registry.streamChanges.pipe( + Stream.runHead, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const firstRefresh = yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) + .pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + const duplicateRefresh = yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + yield* Deferred.succeed(releaseProbe, undefined); + yield* Fiber.join(firstRefresh); + yield* Fiber.join(duplicateRefresh); + const published = yield* Fiber.join(workspaceUpdate); + assert.strictEqual(published._tag, "Some"); + const providers = yield* registry.getProviders; + assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); + assert.deepStrictEqual( + providers[0]?.workspaceSnapshots?.[0]?.skills, + scopedProvider.skills, + ); + assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [ + COMPACT_SLASH_COMMAND, + ]); + assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true); + yield* Ref.set(scopedResult, { + ...scopedProvider, + status: "error", + slashCommandsPending: false, + }); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 3); + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands, + scopedProvider.slashCommands, + ); + assert.strictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, + undefined, + ); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 3); + const newSkills = [ + ...scopedProvider.skills, + { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, + ]; + yield* Ref.set(scopedResult, { ...scopedProvider, skills: newSkills }); + yield* registry.refreshWorkspaceSnapshot({ + instanceId, + cwd: "/workspace", + fresh: true, + }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 4); + assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), + [newSkills], + ); + + // A slow fresh scan that read older files must not overwrite a + // newer scan that finished first. + const slowStarted = yield* Deferred.make(); + const releaseSlow = yield* Deferred.make(); + yield* Ref.set(scanGate, { started: slowStarted, release: releaseSlow }); + yield* Ref.set(scopedResult, scopedProvider); + const slowScan = yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace", fresh: true }) + .pipe(Effect.forkChild); + yield* Deferred.await(slowStarted); + const latestSkills = [ + ...newSkills, + { name: "latest", path: "/workspace/latest/SKILL.md", enabled: true }, + ]; + yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills }); + yield* registry.refreshWorkspaceSnapshot({ + instanceId, + cwd: "/workspace", + fresh: true, + }); + yield* Deferred.succeed(releaseSlow, undefined); + yield* Fiber.join(slowScan); + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), + [latestSkills], + ); + + yield* Ref.set(instancesRef, [rebuiltInstance]); + yield* PubSub.publish(registryChanges, undefined); + let rebuilt = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; + attempt += 1 + ) { + yield* Effect.yieldNow; + rebuilt = yield* registry.getProviders; + } + assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); + assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("refreshes OpenCode catalogs and preserves other providers", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const openCodeDriver = ProviderDriverKind.make("opencode"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const openCodeInstanceId = ProviderInstanceId.make("opencode"); + const codexRefreshCalls = yield* Ref.make(0); + const openCodeRefreshCalls = yield* Ref.make(0); + const codexProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const failedOpenCodeProvider = { + instanceId: openCodeInstanceId, + driver: openCodeDriver, + status: "error", + enabled: true, + installed: true, + auth: { status: "unknown" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + message: "Failed to refresh OpenCode models.", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const recoveredOpenCodeProvider = { + ...failedOpenCodeProvider, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:01:00.000Z", + message: "One upstream provider connected through OpenCode.", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + } as const satisfies ServerProvider; + const changedCatalogProvider = { + ...recoveredOpenCodeProvider, + checkedAt: "2026-06-10T00:02:00.000Z", + models: [ + { + slug: "anthropic/claude-sonnet-4", + name: "Claude Sonnet 4", + subProvider: "Anthropic", + isCustom: false, + capabilities: null, + }, + ], + } as const satisfies ServerProvider; + const catalogSnapshot = yield* Ref.make(recoveredOpenCodeProvider); + const instances = [ + { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(codexProvider), + refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe( + Effect.as(codexProvider), + ), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }, + { + instanceId: openCodeInstanceId, + driverKind: openCodeDriver, + continuationIdentity: { + driverKind: openCodeDriver, + continuationKey: "opencode:instance:opencode", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: openCodeDriver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(failedOpenCodeProvider), + refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe( + Effect.andThen(Ref.get(catalogSnapshot)), + ), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }, + ] satisfies ReadonlyArray; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-reconnect-refresh-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const initialProviders = yield* registry.getProviders; + assert.strictEqual( + initialProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.status, + "error", + ); + + const recoveredProviders = yield* registry.refresh(); + assert.deepStrictEqual( + recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId) + ?.models, + recoveredOpenCodeProvider.models, + ); + assert.deepStrictEqual( + recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), + withBundledCompatibility(codexProvider), + ); + + yield* Ref.set(catalogSnapshot, changedCatalogProvider); + const changedProviders = yield* registry.refresh(); + assert.deepStrictEqual( + changedProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.models, + changedCatalogProvider.models, + ); + assert.deepStrictEqual( + changedProviders.find((provider) => provider.instanceId === codexInstanceId), + withBundledCompatibility(codexProvider), + ); + }).pipe(Effect.provide(runtimeServices)); + + assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2); + assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2); + }), + ); + + it("persists merged provider snapshots for the providers that were refreshed", () => { + const previousProviders = [ + { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], + slashCommands: [], + skills: [], + }, + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }, + ] as const satisfies ReadonlyArray; + const refreshedCursor = { + ...previousProviders[0], + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + + const mergedProviders = ProviderRegistry.mergeProviderSnapshots(previousProviders, [ + refreshedCursor, + ]); + const persistedProviders = ProviderRegistry.selectProvidersByKind( + mergedProviders, + new Set([ProviderDriverKind.make("cursor")]), + ); + + assert.deepStrictEqual(persistedProviders, [ + { + ...refreshedCursor, + models: [...previousProviders[0].models], + }, + ]); + }); + + it.effect("persists the merged snapshot when a live update has empty models", () => + Effect.gen(function* () { + const cursorDriver = ProviderDriverKind.make("cursor"); + const cursorInstanceId = ProviderInstanceId.make("cursor"); + const initialProvider = { + instanceId: cursorInstanceId, + driver: cursorDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + ], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...initialProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + const changes = yield* PubSub.unbounded(); + const instance = { + instanceId: cursorInstanceId, + driverKind: cursorDriver, + continuationIdentity: { + driverKind: cursorDriver, + continuationKey: "cursor:instance:cursor", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: cursorDriver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(refreshedProvider), + streamChanges: Stream.fromPubSub(changes), + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-merged-persist-", + }), + ), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: cursorInstanceId, + }); + + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ + ...initialProvider.models, + ]); + const persisted = yield* awaitPersistedProvider(registry, refreshedProvider.checkedAt); + yield* PubSub.publish(changes, refreshedProvider); + yield* Fiber.join(persisted); + const cachedProvider = yield* readProviderStatusCache(filePath); + + assert.deepStrictEqual( + cachedProvider, + withBundledCompatibility({ + ...refreshedProvider, + models: [...initialProvider.models], + }), + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect( + "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", + () => + Effect.gen(function* () { + const openCodeDriver = ProviderDriverKind.make("opencode"); + const openCodeInstanceId = ProviderInstanceId.make("opencode"); + const initialProvider = { + instanceId: openCodeInstanceId, + driver: openCodeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const authoritativeProvider = { + ...initialProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [initialProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + const changes = yield* PubSub.unbounded(); + const instance = { + instanceId: openCodeInstanceId, + driverKind: openCodeDriver, + continuationIdentity: { + driverKind: openCodeDriver, + continuationKey: "opencode:instance:opencode", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: openCodeDriver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(authoritativeProvider), + streamChanges: Stream.fromPubSub(changes), + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-opencode-authoritative-persist-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: openCodeInstanceId, + }); + + const authoritativePersisted = yield* awaitPersistedProvider( + registry, + authoritativeProvider.checkedAt, + ); + yield* PubSub.publish(changes, authoritativeProvider); + yield* Fiber.join(authoritativePersisted); + let cachedProvider = yield* readProviderStatusCache(filePath); + + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + + const failedPersisted = yield* awaitPersistedProvider( + registry, + failedProvider.checkedAt, + ); + yield* PubSub.publish(changes, failedProvider); + yield* Fiber.join(failedPersisted); + cachedProvider = yield* readProviderStatusCache(filePath); + + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ + authoritativeProvider.models[0]!, + ]); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("returns the cached provider list when a manual refresh fails", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const cachedProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(cachedProvider), + refresh: Effect.die(new Error("simulated refresh failure")), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const layerInstanceRegistry = 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 scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-refresh-failure-", + }), + ), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + + assert.deepStrictEqual(yield* registry.getProviders, [ + withBundledCompatibility(cachedProvider), + ]); + assert.deepStrictEqual(yield* registry.refresh(codexDriver), [ + withBundledCompatibility(cachedProvider), + ]); + assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ + withBundledCompatibility(cachedProvider), + ]); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("keeps consuming registry changes after one sync fails", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const claudeDriver = ProviderDriverKind.make("claudeAgent"); + const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); + const codexProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const claudeProvider = { + instanceId: claudeInstanceId, + driver: claudeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:01:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const makeInstance = (provider: ServerProvider): ProviderInstance => ({ + instanceId: provider.instanceId, + driverKind: provider.driver, + continuationIdentity: { + driverKind: provider.driver, + continuationKey: `${provider.driver}:instance:${provider.instanceId}`, + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: provider.driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }); + const codexInstance = makeInstance(codexProvider); + const claudeInstance = makeInstance(claudeProvider); + const changes = yield* PubSub.unbounded(); + const instancesRef = yield* Ref.make>([codexInstance]); + const failNextList = yield* Ref.make(false); + const wait = () => Effect.yieldNow; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === instanceId), + ), + ), + listInstances: Effect.gen(function* () { + const shouldFail = yield* Ref.get(failNextList); + if (shouldFail) { + yield* Ref.set(failNextList, false); + return yield* Effect.die(new Error("simulated registry list failure")); + } + return yield* Ref.get(instancesRef); + }), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(changes), + subscribeChanges: PubSub.subscribe(changes), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-sync-failure-", + }), + ), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + assert.deepStrictEqual(yield* registry.getProviders, [ + withBundledCompatibility(codexProvider), + ]); + + yield* Ref.set(failNextList, true); + yield* PubSub.publish(changes, undefined); + + yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); + yield* PubSub.publish(changes, undefined); + + let providers = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && !providers.some((provider) => provider.instanceId === claudeInstanceId); + attempt += 1 + ) { + yield* wait(); + providers = yield* registry.getProviders; + } + + assert.deepStrictEqual( + providers.map((provider) => provider.instanceId).toSorted(), + [codexInstanceId, claudeInstanceId].toSorted(), + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + // This test intentionally avoids `mockCommandSpawnerLayer` so the real + // `probeCodexAppServerProvider` path runs — including the full + // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`. + // We point `binaryPath` at a name that cannot exist on any machine so + // the real `ChildProcessSpawner` deterministically returns ENOENT; the + // probe wraps that as `CodexAppServerSpawnError` and + // `checkCodexProviderStatus` turns it into the user-visible "not + // installed" error snapshot. If the aggregator's `syncLiveSources` + // breaks — the `codex_personal`-never-probes bug we are guarding + // against — that snapshot never lands in `getProviders` and the + // assertions below fail. + it.effect("propagates real Codex probe failures to the aggregator at boot", () => + Effect.gen(function* () { + const missingBinary = `t3code_codex_missing_`; + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + // Disable every built-in probe that would otherwise spawn + // on the CI host. `enabled: false` short-circuits each + // driver's probe *before* it touches the spawner, so the + // test environment stays isolated from the dev + // machine's PATH. + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + // `providerInstances` keys are branded `ProviderInstanceId`; + // the branded index signature rejects plain string literals + // at the TS level even though the runtime schema happily + // accepts + decodes them. Cast the patch to `unknown` so + // the `Schema.decodeSync` below does the real validation. + providerInstances: { + // Matches the shape the user had in `.t3/dev/settings.json` + // when the bug was reported: a custom enabled Codex instance + // pointing at a binary the server has to actually spawn. + codex_personal: { + driver: "codex", + displayName: "Codex Personal", + enabled: true, + config: { + binaryPath: missingBinary, + homePath: `/tmp/${missingBinary}_home`, + }, + }, + } as unknown as ContractServerSettings["providerInstances"], + }), + ), + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const layerProviderRegistry = ProviderRegistry.layer.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydration.layer), + Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge(layerTestHttpClient), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), + Layer.provideMerge( + OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), + ), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + // 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 + // what exercises the same failure mode as a misconfigured + // production `binaryPath`. + ); + const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( + Scope.provide(scope), + ); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + let providers = yield* registry.getProviders; + for ( + let attempts = 0; + attempts < 50 && + providers.find((provider) => provider.instanceId === "codex_personal")?.status !== + "error"; + attempts += 1 + ) { + yield* Effect.yieldNow; + providers = yield* registry.getProviders; + } + const codexPersonal = providers.find( + (provider) => provider.instanceId === "codex_personal", + ); + assert.notStrictEqual( + codexPersonal, + undefined, + `Expected the aggregator to know about codex_personal; instead saw: ${providers + .map((provider) => provider.instanceId) + .join(", ")}`, + ); + assert.strictEqual( + codexPersonal?.status, + "error", + "Real Codex probe against a missing binary should surface as 'error' in the aggregator", + ); + assert.strictEqual(codexPersonal?.installed, false); + assert.include(codexPersonal?.message, missingBinary); + assert.include(codexPersonal?.message, "Settings → Providers → Codex → Binary path"); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + // A binary path change must rebuild Codex and publish its new probe result. + it.effect("re-probes when settings change the codex binaryPath", () => + Effect.gen(function* () { + const firstMissing = `t3code_codex_first_`; + const secondMissing = `t3code_codex_second_`; + const spawnedCommands: Array = []; + const secondProbeStarted = yield* Deferred.make(); + const releaseSecondProbe = yield* Deferred.make(); + const allowLazySettingsStream = yield* Deferred.make(); + const mutableServerSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + codex: { enabled: true, binaryPath: firstMissing }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + }), + ), + ); + const serverSettings = { + ...mutableServerSettings, + streamChanges: Stream.unwrap( + Deferred.await(allowLazySettingsStream).pipe( + Effect.as(mutableServerSettings.streamChanges), + ), + ), + } satisfies ServerSettingsModule.ServerSettingsService["Service"]; + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const layerProviderRegistry = ProviderRegistry.layer.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydration.layer), + Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge(layerTestHttpClient), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), + Layer.provideMerge( + OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), + ), + Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => + ChildProcessSpawner.make((command) => { + if (command._tag !== "StandardCommand") return spawner.spawn(command); + spawnedCommands.push(command.command); + const beforeSpawn = + command.command === secondMissing + ? Deferred.succeed(secondProbeStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSecondProbe)), + ) + : Effect.void; + return beforeSpawn.pipe(Effect.andThen(spawner.spawn(command))); + }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + ); + const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( + Scope.provide(scope), + ); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const codexSnapshots = registry.streamChanges.pipe( + Stream.map((providers) => + providers.find((provider) => provider.instanceId === "codex"), + ), + Stream.filter((provider): provider is ServerProvider => provider !== undefined), + ); + const firstError = yield* Stream.toPull( + codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")), + ); + const currentCodex = (yield* registry.getProviders).find( + (provider) => provider.instanceId === "codex", + ); + const initialCodex = + currentCodex?.status === "error" ? currentCodex : (yield* firstError)[0]; + assert.strictEqual(initialCodex?.status, "error"); + assert.strictEqual(initialCodex?.installed, false); + assert.deepStrictEqual(spawnedCommands, [firstMissing]); + + const pendingRebuild = yield* Stream.toPull( + codexSnapshots.pipe( + Stream.filter((provider) => provider.status === "warning" && !provider.installed), + ), + ); + yield* serverSettings.updateSettings({ + providers: { + codex: { enabled: true, binaryPath: secondMissing }, + }, + }); + // Start the lazy stream only after publishing. A watcher that did + // not subscribe before forking has already lost this update. + yield* Deferred.succeed(allowLazySettingsStream, undefined); + + // Hold the second probe until the aggregator sees the rebuilt + // instance. Its next error must come from the new executable. + yield* Deferred.await(secondProbeStarted); + yield* pendingRebuild; + const rebuiltError = yield* Stream.toPull( + codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")), + ); + yield* Deferred.succeed(releaseSecondProbe, undefined); + const [reprobedCodex] = yield* rebuiltError; + assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); + assert.strictEqual(reprobedCodex?.status, "error"); + assert.strictEqual(reprobedCodex?.installed, false); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("includes unavailable instance snapshots in getProviders", () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + providerInstances: { + ghost_main: { + driver: "ghostDriver", + displayName: "A fork-only driver we don't ship", + enabled: false, + config: { arbitrary: "payload" }, + }, + } as unknown as ContractServerSettings["providerInstances"], + }), + ), + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const layerProviderRegistry = ProviderRegistry.layer.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydration.layer), + Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge(layerTestHttpClient), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), + Layer.provideMerge( + OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + ); + const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( + Scope.provide(scope), + ); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const providers = yield* registry.getProviders; + const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); + + assert.notStrictEqual(ghost, undefined); + assert.strictEqual(ghost?.driver, "ghostDriver"); + assert.strictEqual(ghost?.availability, "unavailable"); + assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect( + "keeps Cursor disabled and skips provider probing when settings use their defaults", + () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + codex: { + enabled: false, + }, + grok: { + enabled: false, + }, + }, + }), + ), + ); + let cursorSpawned = false; + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const layerProviderRegistry = ProviderRegistry.layer.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydration.layer), + Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge(layerTestHttpClient), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), + Layer.provideMerge( + OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), + ), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + Layer.provideMerge( + layerMockCommandSpawner((command, args) => { + if (command === "cursor-agent") { + cursorSpawned = true; + } + const joined = args.join(" "); + if (joined === "--version") { + return { + stdout: `${command} 1.0.0\n`, + stderr: "", + code: 0, + }; + } + if (joined === "auth status") { + return { + stdout: '{"authenticated":true}\n', + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected args: ${command} ${joined}`); + }), + ), + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + layerProviderRegistry, + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const providers = yield* registry.getProviders; + const cursorProvider = providers.find( + (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), + ); + + assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ + "antigravity", + "claudeAgent", + "codex", + "cursor", + "grok", + "opencode", + "pi", + ]); + assert.strictEqual(cursorProvider?.enabled, false); + assert.strictEqual(cursorProvider?.status, "disabled"); + assert.strictEqual(cursorProvider?.message, "Cursor is disabled in T3 Code settings."); + assert.strictEqual(cursorSpawned, false); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("skips codex probes entirely when the provider is disabled", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( + Effect.provide(layerFailingSpawner("spawn codex ENOENT")), + ); + assert.strictEqual(status.enabled, false); + assert.strictEqual(status.status, "disabled"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); + }), + ); + }); + + // ── checkClaudeProviderStatus tests ────────────────────────── + + describe("checkClaudeProviderStatus", () => { + it.effect("returns ready when claude is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => + Effect.gen(function* () { + // Bedrock authenticates via external AWS credentials, so the SDK init + // reports only `apiProvider` with no subscription or token. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ apiProvider: "bedrock" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "bedrock"); + assert.strictEqual(status.auth.label, "Amazon Bedrock"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns a display label for claude subscription types", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ subscriptionType: "maxplan" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "maxplan"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("reads banked resets only for subscription logins", () => + Effect.gen(function* () { + const check = (overrides: Partial) => + checkClaudeProviderStatus( + defaultClaudeSettings, + () => + Effect.succeed({ + email: undefined, + subscriptionType: undefined, + tokenSource: undefined, + apiProvider: undefined, + slashCommands: [], + usage: { rate_limits_available: true, rate_limits: {} }, + ...overrides, + }), + undefined, + undefined, + undefined, + undefined, + () => Effect.succeed({ availableCount: 2 }), + ); + const subscription = yield* check({ subscriptionType: "max" }); + const bedrock = yield* check({ apiProvider: "bedrock" }); + assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 }); + assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("does not duplicate Claude in full subscription labels", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max Subscription", + }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "Claude Max Subscription"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("does not duplicate Claude in provider-prefixed subscription names", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max", + }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "Claude Max"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns claude auth email from initialization result", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ email: "claude@example.com" }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.email, "claude@example.com"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: + '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { + const claudeConfigDir = "/tmp/t3code-claude-home"; + const recorded = recordingMockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }); + + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { + ...defaultClaudeSettings, + homePath: claudeConfigDir, + }, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + // The home is resolved through the host Path before it reaches the env. + assert.deepStrictEqual( + recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), + [(yield* Path.Path).resolve(claudeConfigDir)], + ); + }).pipe(Effect.provide(recorded.layer)); + }); + + it.effect("includes probed claude slash commands in the provider snapshot", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ], + }), + ); + + assert.deepStrictEqual(status.slashCommands.slice(1), [ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ]); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("deduplicates probed claude slash commands by name", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ + { + name: "ui", + description: "Explore and refine UI", + }, + { + name: "ui", + input: { hint: "component-or-screen" }, + }, + ], + }), + ); + + assert.deepStrictEqual(status.slashCommands, [ + COMPACT_SLASH_COMMAND, + { + name: "ui", + description: "Explore and refine UI", + input: { hint: "component-or-screen" }, + }, + ]); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns an api key label for claude api key auth", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.auth.label, "Claude API Key"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"api-key"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unavailable when claude is missing", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual(status.message, "Claude Agent CLI (`claude`) was not found on PATH."); + }).pipe(Effect.provide(layerFailingSpawner("spawn claude ENOENT"))), + ); + + it.effect("returns error when version check fails with non-zero exit code", () => { + const secretStderr = "Something went wrong: secret-token-value"; + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); + assert.ok(!(status.message ?? "").includes(secretStderr)); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") + return { + stdout: "", + stderr: secretStderr, + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ); + }); + + it.effect("returns warning when the Claude initialization result is unavailable", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + noClaudeCapabilities, + ); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual( + status.message, + "Could not verify Claude authentication status from initialization result.", + ); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); +}); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..898bb955e5eded8e90c5b828f02de5877ff5638e --- /dev/null +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -0,0 +1,1086 @@ +/** + * ProviderRegistry - Provider snapshot service. + * + * Owns provider install/auth/version/model snapshots and exposes the latest + * provider state to transport layers. + * + * The live layer aggregates per-instance snapshot streams into a + * single materialized list. + * + * Historically this Layer composed four per-kind Live Layers + * (`CodexProviderLive`, `ClaudeProviderLive`, …) that each exposed a + * `ServerProviderShape`. Those Lives were deleted during the driver / + * instance refactor — every driver now carries its `snapshot: ServerProviderShape` + * bundled onto the `ProviderInstance` the registry produces. + * + * Each configured instance (including multi-instance setups like + * `codex_personal` + `codex_work`) contributes one `ProviderSnapshotSource`, + * keyed by `instanceId`. Instances whose driver is unavailable or whose + * config failed to decode are merged from `instanceRegistry.listUnavailable` + * as shadow snapshots so the UI can render their exact unavailable reason. + * + * Cache paths on disk are now keyed by `instanceId`. Because + * `defaultInstanceIdForDriver(kind) === kind` for built-in kinds, existing + * `.json` files remain the on-disk location for that driver's default + * instance. Identity-less legacy cache contents are ignored and replaced by + * the first live refresh. + * + * @module ProviderRegistry + */ +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderUpdateState, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as Semaphore from "effect/Semaphore"; + +import * as ModelManifest from "./ModelManifest.ts"; +import { applyProviderCompatibility } from "./providerCompatibility.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import { + hydrateCachedProvider, + isCachedProviderCorrelated, + orderProviderSnapshots, + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "./providerStatusCache.ts"; +import type { ProviderInstance, ProviderWorkspaceSnapshot } from "./ProviderDriver.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilities, +} from "./providerMaintenance.ts"; +import type { ProviderSnapshotSource } from "./builtInProviderCatalog.ts"; + +export type ProviderMaintenanceActionKind = "update"; + +export class ProviderRegistry extends Context.Service< + ProviderRegistry, + { + /** + * Read the latest 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>; + + /** + * Fill the skills and slash commands snapshot for one cwd. A cwd that + * already has a snapshot is left alone unless `fresh` is set. A fresh scan + * also refreshes the instance's machine snapshot and drops other + * instances' snapshots for the cwd. + */ + readonly refreshWorkspaceSnapshot: (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + readonly fresh?: boolean; + }) => Effect.Effect>; + + /** + * Resolve the maintenance capabilities owned by one live provider instance. + * Falls back to manual-only capabilities when the instance is not live. + * `fresh` re-derives ownership from the executable instead of the cache. + */ + readonly getProviderMaintenanceCapabilitiesForInstance: ( + instanceId: ProviderInstanceId, + provider: ProviderDriverKind, + options?: { readonly fresh?: boolean }, + ) => 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>; + } +>()("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 MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; + +function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): ServerProvider { + return provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === cwd) + ? { + ...provider, + workspaceSnapshots: provider.workspaceSnapshots.filter((snapshot) => snapshot.cwd !== cwd), + } + : provider; +} + +export function upsertProviderWorkspaceSnapshot( + provider: ServerProvider, + cwd: string, + scopedSnapshot: ProviderWorkspaceSnapshot, +): ServerProvider { + const workspaceSnapshot = { + cwd, + checkedAt: scopedSnapshot.checkedAt, + slashCommands: scopedSnapshot.slashCommandsPending + ? (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd)?.slashCommands ?? + scopedSnapshot.slashCommands) + : scopedSnapshot.slashCommands, + ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), + skills: scopedSnapshot.skills, + } satisfies NonNullable[number]; + return { + ...provider, + workspaceSnapshots: [ + ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd), + workspaceSnapshot, + ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER), + }; +} + +const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { + if (provider.driver === ProviderDriverKind.make("acpRegistry")) { + // ACP Registry discovery probes return the agent's complete inventory, so + // a completed probe (ready and authenticated) replaces the model list — + // otherwise agents that rename or collapse models leave stale entries + // pinned forever through the snapshot cache. Readiness-only and failed + // probe snapshots only know the "default" placeholder and stay partial. + return !( + provider.installed && + provider.status === "ready" && + provider.auth.status === "authenticated" + ); + } + + const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); + const isCodex = provider.driver === ProviderDriverKind.make("codex"); + if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { + return true; + } + + if ( + (isAntigravity || isCodex) && + (!provider.enabled || provider.auth.status === "unauthenticated") + ) { + return false; + } + + // Successful discovery replaces these inventories so cached retired models disappear. + // Antigravity's local health check does not authenticate or discover models. + const isPendingAntigravityAuthentication = + isAntigravity && provider.status === "warning" && provider.auth.status === "unknown"; + const isPendingInitialProbe = + provider.enabled && !provider.installed && provider.status === "warning"; + const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; + return ( + isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail + ); +}; + +const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => + provider.driver === ProviderDriverKind.make("opencode") && + shouldRetainMissingProviderModels(provider); + +const mergeProviderModels = ( + provider: ServerProvider, + previousModels: ReadonlyArray, + nextModels: ReadonlyArray, +): ReadonlyArray => { + const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); + // Custom rows are derived from settings and every snapshot carries the full + // current list, so a custom model missing from `nextModels` was removed by + // the user and must not be resurrected from the previous snapshot. + const retainablePreviousModels = previousModels.filter((model) => !model.isCustom); + + if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { + return retainablePreviousModels; + } + + 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 shouldRetainMissingModels + ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))] + : mergedModels; +}; + +/** + * Antigravity's health check only initializes the agent, so after a server + * restart it reports the account as unchecked. The saved Google login still + * works, and the previous snapshot proves it. Carry that account state until + * a session, refresh, or sign-out reports something new. A confirmed missing + * installation, sign-out, disabled instance, or a changed sign-in method is + * never overridden. + */ +const carrySavedAntigravityAccount = ( + previousProvider: ServerProvider, + nextProvider: ServerProvider, +): Pick | undefined => { + const antigravity = ProviderDriverKind.make("antigravity"); + if ( + nextProvider.driver !== antigravity || + previousProvider.driver !== antigravity || + !nextProvider.enabled || + nextProvider.auth.status !== "unknown" || + previousProvider.auth.status !== "authenticated" || + (nextProvider.auth.type !== undefined && + nextProvider.auth.type !== previousProvider.auth.type) || + (!nextProvider.installed && nextProvider.status !== "warning") + ) { + return undefined; + } + // The pending boot probe (`installed: false`, warning) and a failed probe + // keep their own status; only a passed health check reads as ready. + const status = + nextProvider.installed && nextProvider.status === "warning" ? "ready" : nextProvider.status; + return { auth: previousProvider.auth, status }; +}; + +export const mergeProviderSnapshot = ( + previousProvider: ServerProvider | undefined, + nextProvider: ServerProvider, +): ServerProvider => { + if (!previousProvider) { + return nextProvider; + } + const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); + // "Google account access is not checked yet" describes the probe, not the + // account; it must not outlive the state it explained. + const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; + return { + ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider), + ...savedAccount, + models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), + ...(nextProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: nextProvider.workspaceSnapshots } + : previousProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: previousProvider.workspaceSnapshots } + : {}), + ...(shouldRetainMissingOpenCodeMetadata(nextProvider) + ? { + slashCommands: + nextProvider.slashCommands.length === 0 + ? previousProvider.slashCommands + : nextProvider.slashCommands, + skills: nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, + } + : {}), + }; +}; + +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)); + +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 layer = Layer.effect( + ProviderRegistry, + Effect.gen(function* () { + const instanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const manifestService = yield* ModelManifest.ModelManifest; + const serviceScope = yield* Effect.scope; + const config = yield* ServerConfig.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 initialManifest = yield* manifestService.current; + const classifyCompatibility = ( + provider: ServerProvider, + manifest: ModelManifest.ModelManifestData, + ) => + applyProviderCompatibility( + provider, + manifest.compatibility, + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + ); + const providersRef = yield* Ref.make>( + cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), + ); + const workspaceRefreshesRef = yield* Ref.make< + ReadonlyMap> + >(new Map()); + 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)); + const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; + yield* writeProviderStatusCache({ filePath, provider: machineProvider }).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 manifest = yield* manifestService.current; + 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()].map((provider) => + classifyCompatibility(provider, manifest), + ), + ); + 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 compatibilityRefreshRunning = yield* Ref.make(false); + const syncProvider = Effect.fn("syncProvider")(function* ( + provider: ServerProvider, + options?: { + readonly publish?: boolean; + }, + ) { + const providers = yield* upsertProviders([provider], options); + // Reclassify the current read model after fetching. Never republish the + // probe captured before the fetch: a newer health result may have landed. + if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { + yield* manifestService.refresh.pipe( + Effect.andThen(upsertProviders([], { persist: false })), + Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)), + Effect.forkIn(serviceScope), + ); + } + return providers; + }); + + 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, + options?: { readonly fresh?: boolean }, + ) { + // Read the instance registry, not `liveSubsRef`: the latter trails + // reconciliation, and an update must never run a retired instance's + // command against a freshly configured executable. + const instance = yield* instanceRegistry.getInstance(instanceId); + if (!instance || instance.driverKind !== provider) { + return makeManualProviderMaintenanceCapabilities(provider); + } + return yield* instance.snapshot.resolveMaintenance(options); + }); + + /** + * 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); + } + + const rebuiltInstanceIds = new Set( + newlyAdded + .map(([instanceId]) => instanceId) + .filter((instanceId) => previousSubs.has(instanceId)), + ); + if (rebuiltInstanceIds.size > 0) { + const [previousProviders, providers] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const providers = previousProviders.map((provider) => { + if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; + const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; + return machineSnapshot; + }); + return [[previousProviders, providers] as const, providers]; + }, + ); + if (haveProvidersChanged(previousProviders, providers)) { + yield* PubSub.publish(changesPubSub, providers); + } + } + + // 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); + }); + + const updateProviders = ( + update: (providers: ReadonlyArray) => ReadonlyArray, + ) => + Ref.modify(providersRef, (currentProviders) => { + const nextProviders = update(currentProviders); + return [[currentProviders, nextProviders] as const, nextProviders]; + }).pipe( + Effect.tap(([previousProviders, nextProviders]) => + haveProvidersChanged(previousProviders, nextProviders) + ? PubSub.publish(changesPubSub, nextProviders) + : Effect.void, + ), + Effect.map(([, nextProviders]) => nextProviders), + ); + + const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + readonly fresh?: boolean; + }) { + // Fresh scans drop other instances' snapshots for this cwd first, so a + // composer on one of them scans again on next use, even when this + // instance is gone or cannot be scanned. + if (input.fresh) { + yield* updateProviders((providers) => + providers.map((candidate) => + candidate.instanceId === input.instanceId + ? candidate + : dropProviderWorkspaceSnapshot(candidate, input.cwd), + ), + ); + } + const providers = yield* Ref.get(providersRef); + const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); + const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => + candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); + const scannedFrom = workspaceSnapshotOf(provider); + if ( + !provider || + !provider.enabled || + (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending) + ) { + return providers; + } + const instance = yield* instanceRegistry.getInstance(input.instanceId); + if (!instance?.snapshotForCwd) return providers; + const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { + const current = refreshes.get(instance); + if (current?.has(input.cwd)) return [false, refreshes] as const; + const next = new Map(refreshes); + next.set(instance, new Set(current).add(input.cwd)); + return [true, next] as const; + }); + // A fresh scan never joins a running one, which may predate the change. + if (!claimed && !input.fresh) return yield* Ref.get(providersRef); + // Fresh scans also re-read the machine snapshot after invalidating caches. + const refreshMachineSnapshot = input.fresh + ? (instance.invalidateCaches ?? Effect.void).pipe( + Effect.andThen(refreshInstance(input.instanceId)), + ) + : Effect.void; + return yield* refreshMachineSnapshot.pipe( + Effect.andThen(instance.snapshotForCwd(input.cwd)), + Effect.flatMap((scopedSnapshot) => + scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined + ? Ref.get(providersRef) + : instanceRegistry.getInstance(input.instanceId).pipe( + Effect.flatMap((currentInstance) => { + if (currentInstance !== instance) return Ref.get(providersRef); + // Write only if the cwd's snapshot did not change during the + // scan. A session event or another scan that landed first is newer. + return updateProviders((currentProviders) => + currentProviders.map((candidate) => + candidate.instanceId === input.instanceId && + Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) + ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) + : candidate, + ), + ); + }), + ), + ), + Effect.ensuring( + claimed + ? Ref.update(workspaceRefreshesRef, (refreshes) => { + const next = new Map(refreshes); + const current = new Set(next.get(instance)); + current.delete(input.cwd); + if (current.size) next.set(instance, current); + else next.delete(instance); + return next; + }) + : Effect.void, + ), + ); + }); + + return { + getProviders: Ref.get(providersRef), + refresh: (provider?: ProviderDriverKind) => + refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshInstance: (instanceId: ProviderInstanceId) => + refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshWorkspaceSnapshot: (input) => + refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), + getProviderMaintenanceCapabilitiesForInstance, + setProviderMaintenanceActionState, + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + } satisfies ProviderRegistry["Service"]; + }), +); diff --git a/apps/server/src/provider/ProviderUsageLimitsIngestion.ts b/apps/server/src/provider/ProviderUsageLimitsIngestion.ts new file mode 100644 index 0000000000000000000000000000000000000000..aebd4d12159efeaba33c947c5fa19ecb2ff187ef --- /dev/null +++ b/apps/server/src/provider/ProviderUsageLimitsIngestion.ts @@ -0,0 +1,7 @@ +/** + * V2 drivers fold live account usage events directly into their managed + * snapshots. Retained as an empty layer for the shared server composition. + */ +import * as Layer from "effect/Layer"; + +export const layer = Layer.empty; diff --git a/apps/server/src/provider/ServerProvider.ts b/apps/server/src/provider/ServerProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..77c16c14ee229cae75e02cc5ce80b76f1be4bf00 --- /dev/null +++ b/apps/server/src/provider/ServerProvider.ts @@ -0,0 +1,26 @@ +import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; +import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; + +export interface ServerProviderShape { + /** + * Ownership-derived update capabilities. Cached between reads; pass + * `{ fresh: true }` before executing an update so it never trusts a + * resolution older than the click. + */ + readonly resolveMaintenance: (options?: { + readonly fresh?: boolean; + }) => Effect.Effect; + readonly getSnapshot: Effect.Effect; + readonly refresh: Effect.Effect; + readonly streamChanges: Stream.Stream; + /** + * Fold a runtime rate-limit update into the published snapshot without + * waiting for the next status probe. Sparse: windows merge by id and an + * update with no usable window leaves the snapshot untouched. + */ + readonly applyUsageLimits: ( + update: ProviderUsageLimitsUpdate & { readonly checkedAt: string }, + ) => Effect.Effect; +} diff --git a/apps/server/src/provider/Services/ProviderAuthService.ts b/apps/server/src/provider/Services/ProviderAuthService.ts deleted file mode 100644 index 273f1b5337eee088031356c85895b792a854d569..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Services/ProviderAuthService.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { - ChatGptReconnectProfile, - ChatGptTransferredProfile, - ProviderAuthRespondInput, - ProviderAuthStartInput, - ProviderAuthState, - ProviderInstanceId, - ProviderSetupError, -} 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 * as Scope from "effect/Scope"; - -export interface ProviderAuthController { - /** Equal keys mean these instances share credentials on this environment. */ - readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; - readonly reconnectProfile?: ( - methodId: string, - ) => Effect.Effect; - readonly importProfile?: ( - profile: ChatGptTransferredProfile, - stopSessions: Effect.Effect, - ) => Effect.Effect; - readonly adoptCredentials?: ( - update: Effect.Effect, - stopSessions: Effect.Effect, - ) => Effect.Effect; - readonly isChangingCredentials?: Effect.Effect; - readonly invalidate?: Effect.Effect; - readonly refreshMethods?: Effect.Effect; - readonly withAccess?: ( - task: Effect.Effect, - ) => Effect.Effect; - readonly start: ( - ownerSessionId: string, - stopSessions?: Effect.Effect, - methodId?: string, - returnUrl?: string, - callbackMode?: "server" | "client", - ) => Effect.Effect; - readonly complete: ( - ownerSessionId: string, - input: { readonly flowId: string; readonly callbackUrl: string }, - ) => Effect.Effect; - readonly cancel: ( - ownerSessionId: string, - flowId: string, - ) => Effect.Effect; - readonly respond?: ( - ownerSessionId: string, - input: ProviderAuthRespondInput, - ) => Effect.Effect; - /** The controller closes process admission before it stops routed sessions. */ - readonly logout: ( - stopSessions: Effect.Effect, - ) => Effect.Effect; - readonly subscribe: (ownerSessionId: string) => Stream.Stream; - readonly isLogoutPrompt?: (text: string, hasAttachments: boolean) => boolean; -} - -interface ProviderAuthTarget { - readonly instanceId: ProviderInstanceId; -} - -export interface ProviderAuthServiceShape { - readonly reconnectProfile: ( - input: ProviderAuthTarget & { methodId: string }, - ) => Effect.Effect; - readonly importProfile: ( - input: ProviderAuthTarget & { profile: ChatGptTransferredProfile }, - ) => Effect.Effect; - readonly start: ( - input: ProviderAuthStartInput, - ownerSessionId: string, - ) => Effect.Effect; - readonly complete: ( - input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, - ownerSessionId: string, - ) => Effect.Effect; - readonly respond: ( - input: ProviderAuthRespondInput, - ownerSessionId: string, - ) => Effect.Effect; - readonly cancel: ( - input: ProviderAuthTarget & { readonly flowId: string }, - ownerSessionId: string, - ) => Effect.Effect; - readonly logout: ( - input: ProviderAuthTarget, - ) => Effect.Effect; - readonly subscribe: ( - input: ProviderAuthTarget, - ownerSessionId: string, - ) => Stream.Stream; - readonly tryHandlePromptCommand: ( - input: ProviderAuthTarget & { readonly text: string; readonly hasAttachments: boolean }, - ) => Effect.Effect; -} - -export class ProviderAuthService extends Context.Service< - ProviderAuthService, - ProviderAuthServiceShape ->()("t3/provider/Services/ProviderAuthService") {} diff --git a/apps/server/src/provider/Services/ProviderInstanceRegistry.ts b/apps/server/src/provider/Services/ProviderInstanceRegistry.ts deleted file mode 100644 index cfea114266645a4471ecfcc5df99566bd1016761..0000000000000000000000000000000000000000 --- 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 98d450802370b21298174e435c2a6ac82d3c1619..0000000000000000000000000000000000000000 --- 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 94edf8556c8b90dafb61b7c511e92590c8cb3d50..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ /dev/null @@ -1,95 +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>; - - /** - * Fill the skills and slash commands snapshot for one cwd. A cwd that - * already has a snapshot is left alone unless `fresh` is set. A fresh scan - * also refreshes the instance's machine snapshot and drops other - * instances' snapshots for the cwd. - */ - readonly refreshWorkspaceSnapshot: (input: { - readonly instanceId: ProviderInstanceId; - readonly cwd: string; - readonly fresh?: boolean; - }) => Effect.Effect>; - - /** - * Resolve the maintenance capabilities owned by one live provider instance. - * Falls back to manual-only capabilities when the instance is not live. - * `fresh` re-derives ownership from the executable instead of the cache. - */ - readonly getProviderMaintenanceCapabilitiesForInstance: ( - instanceId: ProviderInstanceId, - provider: ProviderDriverKind, - options?: { readonly fresh?: boolean }, - ) => 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/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts deleted file mode 100644 index dfea57cd93b54aeeb227c47616f6f8b35907b522..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Services/ServerProvider.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; -import type * as Effect from "effect/Effect"; -import type * as Stream from "effect/Stream"; -import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; - -export interface ServerProviderShape { - /** - * Ownership-derived update capabilities. Cached between reads; pass - * `{ fresh: true }` before executing an update so it never trusts a - * resolution older than the click. - */ - readonly resolveMaintenance: (options?: { - readonly fresh?: boolean; - }) => Effect.Effect; - readonly getSnapshot: Effect.Effect; - readonly refresh: Effect.Effect; - readonly streamChanges: Stream.Stream; - /** - * Fold a runtime rate-limit update into the published snapshot without - * waiting for the next status probe. Sparse: windows merge by id and an - * update with no usable window leaves the snapshot untouched. - */ - readonly applyUsageLimits: ( - update: ProviderUsageLimitsUpdate & { readonly checkedAt: string }, - ) => Effect.Effect; -} diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/apps/server/src/provider/acp/AcpNativeLogging.test.ts index 84926fbe1d61440f9d36e792fab43f885d006d06..54b485613f379d7dd5954ff9764b7140b9d24b2c 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.test.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.test.ts @@ -8,7 +8,7 @@ import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; import * as AcpErrors from "effect-acp/errors"; -import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; +import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts"; const nodeServicesIt = it.layer(NodeServices.layer); diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index e4388646d0a47d7c0984c68a8e100d1f06d049f9..c909f5a007b8198def306c582e2ff19b25c5b724 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -6,7 +6,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import type * as EffectAcpProtocol from "effect-acp/protocol"; -import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; +import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; import { structuralProtocolMethod, summarizeNativeProtocolPayload, diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts index 559726c65eaab18b1ef084c4209165f4f95f6ee6..98336dbef0bfaf146a048e01a3fef26aff507294 100644 --- a/apps/server/src/provider/builtInProviderCatalog.ts +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -1,6 +1,6 @@ import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; import type * as Stream from "effect/Stream"; -import type { ServerProviderShape } from "./Services/ServerProvider.ts"; +import type { ServerProviderShape } from "./ServerProvider.ts"; export type ProviderSnapshotSource = { /** diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0dcb409ffa3c12be05be00d7326d992595eba809 --- /dev/null +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -0,0 +1,259 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse, UrlParams } from "effect/http"; +import { describe, expect, it } from "vite-plus/test"; + +import * as ClaudeResetCredits from "./claudeResetCredits.ts"; + +const NOW = Date.parse("2026-09-22T12:00:00.000Z"); +const grant = (overrides: Record) => ({ + id: "grant_a", + resets_left: 1, + usable_now: true, + ...overrides, +}); + +const writeLogin = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString( + path.join(directory, ".credentials.json"), + '{"claudeAiOauth":{"accessToken":"oauth-token"}}', + ); + const accountConfigPath = path.join(directory, ".claude.json"); + yield* fs.writeFileString(accountConfigPath, '{"oauthAccount":{"organizationUuid":"org-1"}}'); + return { configDir: directory, accountConfigPath }; +}); + +const respond = (status: number, body: unknown) => + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))), + ); +const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); + +describe("claudeResetCreditsToContract", () => { + it("counts live grants and pins the next usable one", () => { + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { + eligible: true, + next_grant_id: "grant_a", + grants: [ + grant({ resets_left: 2, ends_at: "2026-10-01T00:00:00Z" }), + grant({ id: "paused", paused: true }), + grant({ id: "expired", ends_at: "2026-09-01T00:00:00Z" }), + grant({ id: "garbled", ends_at: "not a date" }), + grant({ id: "date_only", ends_at: "2026-10-01" }), + grant({ id: "impossible", ends_at: "2027-02-30T00:00:00Z" }), + grant({ id: "empty", ends_at: "" }), + grant({ id: "Not Valid" }), + grant({ id: "grant_b", resets_left: 3, usable_now: false }), + ], + }, + NOW, + ), + ).toEqual({ + availableCount: 2, + nextCreditId: "grant_a", + nextExpiresAt: "2026-10-01T00:00:00.000Z", + }); + }); + + it("offers nothing to redeem without a usable next grant or an eligible account", () => { + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { eligible: true, next_grant_id: "grant_a", grants: [grant({ usable_now: false })] }, + NOW, + ), + ).toEqual({ availableCount: 0 }); + expect( + ClaudeResetCredits.claudeResetCreditsToContract({ eligible: true, grants: [grant({})] }, NOW), + ).toEqual({ + availableCount: 0, + }); + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { eligible: false, grants: [grant({})] }, + NOW, + ), + ).toBeUndefined(); + expect(ClaudeResetCredits.claudeResetCreditsToContract(undefined, NOW)).toBeUndefined(); + }); +}); + +effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { + it.effect("reads the grants with the CLI's request", () => + Effect.gen(function* () { + const { configDir } = yield* writeLogin; + const client = HttpClient.make((request) => { + expect(request.method).toBe("GET"); + expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage"); + expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&skip_spend=1"); + expect(request.headers.authorization).toBe("Bearer oauth-token"); + expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20"); + expect(request.headers["user-agent"]).toBe("claude-cli/2.1.0 (external, cli)"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, + }), + ), + ); + }); + const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + ); + expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); + }), + ); + + it.effect("reads nothing from keychain logins or failed requests", () => + Effect.gen(function* () { + const { configDir } = yield* writeLogin; + const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HttpClient.HttpClient, refuseRequests), + ); + const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, respond(429, {})), + ); + expect([darwin, limited]).toEqual([undefined, undefined]); + }), + ); +}); + +const ClaimBody = Schema.fromJsonString( + Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }), +); +const decodeClaimBody = Schema.decodeEffect(ClaimBody); + +const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requestId: "r-1" }) => + Effect.gen(function* () { + const login = yield* writeLogin; + return yield* ClaudeResetCredits.consumeClaudeResetCredit({ + ...login, + version: "2.1.0", + ...ids, + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + Effect.result, + ); + }); + +effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { + it.effect("claims the grant for the organization", () => + Effect.gen(function* () { + const client = HttpClient.make((request) => + Effect.gen(function* () { + expect(request.method).toBe("POST"); + expect(request.url).toBe( + "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits", + ); + expect(request.headers.authorization).toBe("Bearer oauth-token"); + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + expect(yield* decodeClaimBody(body)).toEqual({ + program: "cedar_ember", + grant_id: "grant_a", + request_id: "r-1", + }); + return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); + }).pipe(Effect.orDie), + ); + expect(yield* consume(client)).toMatchObject({ _tag: "Success", success: "reset" }); + }), + ); + + it.effect("maps each answer to an outcome or a failure", () => + Effect.gen(function* () { + for (const [result, outcome] of [ + ["not_limited", "nothingToReset"], + ["already_used", "alreadyRedeemed"], + ["ineligible", "noCredit"], + ] as const) { + expect(yield* consume(respond(200, { result }))).toMatchObject({ success: outcome }); + } + for (const client of [ + respond(200, { result: "cooldown" }), + respond(429, {}), + respond(401, {}), + ]) { + const result = yield* consume(client); + expect(result).toMatchObject({ _tag: "Failure" }); + // Claude answered, so a retry must be a new claim. + if (result._tag === "Failure") { + expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(result.failure)).toBe(true); + } + } + // No answer, or Claude could not confirm the claim: a retry is the same claim. + for (const client of [respond(500, {}), respond(200, { result: "unavailable" })]) { + const unanswered = yield* consume(client); + expect(unanswered).toMatchObject({ _tag: "Failure" }); + if (unanswered._tag === "Failure") { + expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(unanswered.failure)).toBe( + false, + ); + } + } + }), + ); + + it.effect("times out a stalled claim body", () => + Effect.gen(function* () { + const login = yield* writeLogin; + const readingBody = yield* Deferred.make(); + const client = HttpClient.make((request) => { + const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); + Object.defineProperty(response, "json", { + value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)), + }); + return Effect.succeed(response); + }); + const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({ + ...login, + version: "2.1.0", + grantId: "grant_a", + requestId: "r-1", + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + Effect.result, + Effect.forkChild, + ); + yield* Deferred.await(readingBody); + yield* TestClock.adjust("26 seconds"); + expect(yield* Fiber.join(claim)).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "ClaudeResetCreditError", + reason: "requestFailed", + cause: { _tag: "TimeoutError" }, + }, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("refuses malformed ids without sending anything", () => + Effect.gen(function* () { + for (const ids of [ + { grantId: "Bad Grant", requestId: "r-1" }, + { grantId: "grant_a", requestId: "has space" }, + ]) { + expect(yield* consume(refuseRequests, ids)).toMatchObject({ _tag: "Failure" }); + } + }), + ); +}); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f8379e1c037075401e66ad0469764e67044013c --- /dev/null +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -0,0 +1,270 @@ +/** + * Claude banked resets (the CLI's `cedar_ember` program). The CLI reads the + * grants from the OAuth usage endpoint and claims one against the + * organization; this module does the same with the credentials the CLI keeps + * in its config directory. macOS keeps them in the keychain, so there the + * feature is not offered. + * + * @module provider/claudeResetCredits + */ +import * as NodeOS from "node:os"; +import type { + ProviderConsumeResetCreditOutcome, + ServerProviderResetCredits, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; + +const API_BASE = "https://api.anthropic.com"; +const PROGRAM = "cedar_ember"; +const GRANT_ID = /^[a-z0-9_-]{1,40}$/; +const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/; +const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +const Credentials = Schema.Struct({ + claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })), +}); +const Config = Schema.Struct({ + oauthAccount: Schema.optional( + Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }), + ), +}); +const Grant = Schema.Struct({ + id: Schema.String.check(Schema.isPattern(GRANT_ID)), + resets_left: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + ends_at: Schema.optional(Schema.NullOr(Schema.String)), + paused: Schema.optional(Schema.Boolean), + usable_now: Schema.optional(Schema.Boolean), +}); +const decodeGrant = Schema.decodeUnknownOption(Grant); +const CedarEmber = Schema.Struct({ + eligible: Schema.Boolean, + grants: Schema.optional(Schema.Array(Schema.Unknown)), + next_grant_id: Schema.optional(Schema.NullOr(Schema.String)), +}); +const UsageResponse = Schema.Struct({ + cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), +}); +const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); +const ClaimResponse = Schema.Struct({ + result: Schema.Literals([ + "reset", + "already_used", + "not_limited", + "cooldown", + "ineligible", + "unavailable", + ]), +}); + +const RESET_CREDIT_FAILURES = { + malformedCredit: "Claude returned a malformed reset credit.", + loginUnreadable: "Claude could not read its login.", + accountUnreadable: "Claude could not read its account.", + signedOut: "Sign in to Claude again to redeem resets.", + rateLimited: "Claude is rate limiting resets. Try again soon.", + coolingDown: "Claude resets are cooling down. Try again later.", + unconfirmed: + "Claude could not confirm the reset. If you are still limited in a moment, try again.", + requestFailed: "Claude could not redeem the reset.", +} as const; + +class ClaudeResetCreditError extends Schema.TaggedError()( + "ClaudeResetCreditError", + { + reason: Schema.Literals( + Object.keys(RESET_CREDIT_FAILURES) as Array, + ), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return RESET_CREDIT_FAILURES[this.reason]; + } +} + +const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); + +/** + * Every reset failure except `requestFailed` and `unconfirmed` is final: + * Claude answered, or nothing was sent. An unanswered or unconfirmed claim + * retries with the same request id. + */ +export const isSettledClaudeResetCreditFailure = (error: unknown) => + isClaudeResetCreditError(error) && + error.reason !== "requestFailed" && + error.reason !== "unconfirmed"; + +/** Rejects unparseable and calendar-invalid timestamps such as February 30. */ +const isFutureTimestamp = (value: string, nowMs: number) => { + if (!COMPLETE_TIMESTAMP.test(value)) return false; + const [year, month, day] = value.slice(0, 10).split("-").map(Number); + return ( + Date.parse(value) > nowMs && Date.UTC(year!, month! - 1, day!) <= Date.UTC(year!, month!, 0) + ); +}; + +/** Grants that are paused or past `ends_at` cannot be claimed and do not count. */ +export function claudeResetCreditsToContract( + block: unknown, + nowMs: number, +): ServerProviderResetCredits | undefined { + const parsed = decodeCedarEmber(block); + if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; + const live = (parsed.value.grants ?? []) + .flatMap((raw) => Option.toArray(decodeGrant(raw))) + .filter( + (grant) => + !grant.paused && + grant.usable_now && + (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), + ); + const next = live.find((grant) => grant.id === parsed.value.next_grant_id); + const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); + return { + availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, + ...(Option.isSome(nextExpiresAt) + ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } + : {}), + ...(next ? { nextCreditId: next.id } : {}), + }; +} + +const readJson = (schema: S, file: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(file).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), + ); + }); + +const readAccessToken = (configDir: string) => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "darwin") return undefined; + const path = yield* Path.Path; + const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); + return credentials.claudeAiOauth?.accessToken?.trim() || undefined; + }); + +const withClaudeHeaders = (token: string, version: string) => + HttpClientRequest.setHeaders({ + authorization: `Bearer ${token}`, + "anthropic-beta": "oauth-2025-04-20", + "user-agent": `claude-cli/${version} (external, cli)`, + }); + +/** + * Reads the banked resets for the login in `configDir`. Any failure reads as + * "no resets" so the usage bars never break on this optional extra. + */ +export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( + function* (configDir: string, version: string) { + const token = yield* readAccessToken(configDir); + if (!token) return undefined; + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { + urlParams: { cedar_ember: "1", skip_spend: "1" }, + }).pipe(withClaudeHeaders(token, version)), + ); + const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)( + yield* HttpClientResponse.filterStatusOk(response), + ); + return claudeResetCreditsToContract( + body.cedar_ember, + DateTime.toEpochMillis(yield* DateTime.now), + ); + }, + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => undefined), +); + +/** The CLI keeps the account record beside its settings, or in the home directory by default. */ +export const claudeAccountConfigPath = (configDir: string | undefined) => + Effect.map(Path.Path, (path) => + configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"), + ); + +const CLAIM_OUTCOMES = { + reset: "reset", + not_limited: "nothingToReset", + already_used: "alreadyRedeemed", + ineligible: "noCredit", +} as const satisfies Record; + +/** + * Claims `grantId`. `requestId` is the idempotency key: a retry with the same + * id is the same claim. Ids are checked before anything is sent. + */ +export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { + readonly configDir: string; + readonly accountConfigPath: string; + readonly version: string; + readonly grantId: string; + readonly requestId: string; +}) { + if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { + return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); + } + const token = yield* readAccessToken(input.configDir).pipe( + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })), + ); + const config = yield* readJson(Config, input.accountConfigPath).pipe( + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })), + ); + const organization = config.oauthAccount?.organizationUuid?.trim(); + if (!token || !organization) { + return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + } + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute( + HttpClientRequest.post( + new URL( + `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, + API_BASE, + ), + ).pipe( + withClaudeHeaders(token, input.version), + HttpClientRequest.bodyJsonUnsafe({ + program: PROGRAM, + grant_id: input.grantId, + request_id: input.requestId, + }), + ), + ) + .pipe( + Effect.timeout("25 seconds"), + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), + ); + if (response.status === 429) { + return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); + } + if (response.status === 401 || response.status === 403) { + return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + } + const body = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)), + Effect.timeout("25 seconds"), + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), + ); + if (body.result === "cooldown") { + return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); + } + // Claude could not say whether the claim landed, so, like the CLI, keep the + // request id and let the retry ask about the same claim. + if (body.result === "unavailable") { + return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); + } + return CLAIM_OUTCOMES[body.result]; +}); diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3d326a7dcb2104c0ac433852625f113f2798400 --- /dev/null +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts"; + +const checkedAt = "2026-07-18T10:00:00.000Z"; +const noNames = { overageIncluded: undefined } as const; + +describe("claudeUsageResponseToLimits", () => { + it("maps the session, weekly, and model-scoped weekly windows", () => { + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" }, + seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" }, + seven_day_opus: { utilization: 3, resets_at: null }, + // Newer CLIs add this on top of the typed keys; the pinned SDK + // typings do not know it yet. + ...({ + model_scoped: [ + { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" }, + { display_name: "Ghost", utilization: null, resets_at: null }, + ], + } as object), + extra_usage: { + is_enabled: false, + monthly_limit: null, + used_credits: null, + utilization: null, + }, + }, + }, + }), + ).toEqual({ + names: { overageIncluded: "Fable" }, + limits: { + checkedAt, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 54, + windowDurationMins: 300, + resetsAt: "2026-07-18T14:39:00.000Z", + }, + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 18.4, + windowDurationMins: 10080, + resetsAt: "2026-07-24T08:59:00.000Z", + }, + { + id: "seven_day_fable", + kind: "weekly", + label: "Weekly · Fable", + usedPercent: 73, + windowDurationMins: 10080, + resetsAt: "2026-07-24T08:59:00.000Z", + }, + ], + }, + }); + }); + + it("names the overage-included bucket only from a scoped entry that drew a row", () => { + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: { + ...({ + model_scoped: [ + { display_name: "Ghost", utilization: null, resets_at: null }, + { display_name: "Fable", utilization: 5, resets_at: null }, + ], + } as object), + }, + }, + }).names, + ).toEqual({ overageIncluded: "Fable" }); + }); + + it("reports API key and Bedrock accounts as unsupported", () => { + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { rate_limits_available: false, rate_limits: null }, + }).limits, + ).toEqual({ checkedAt, windows: [], unavailable: { reason: "unsupported" } }); + }); + + it("skips a window the endpoint reports without a utilization", () => { + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: null, resets_at: null }, + seven_day: { utilization: 250, resets_at: null }, + }, + }, + }).limits.windows, + ).toEqual([ + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 100, + windowDurationMins: 10080, + }, + ]); + }); +}); + +describe("claudeRateLimitEventToUpdate", () => { + it("scales the 0–1 utilization and epoch-second reset onto the probe's window id", () => { + expect( + claudeRateLimitEventToUpdate( + { + status: "allowed_warning", + rateLimitType: "seven_day", + utilization: 0.85, + resetsAt: 1_784_000_000, + }, + noNames, + ), + ).toEqual({ + windows: [ + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 85, + windowDurationMins: 10080, + resetsAt: "2026-07-14T03:33:20.000Z", + }, + ], + }); + }); + + it("lands the streamed overage-included bucket on the row the probe named", () => { + const event = { + status: "allowed", + rateLimitType: "seven_day_overage_included" as never, + utilization: 0.4, + } as const; + // No probe has named the bucket yet: guessing would open a stray row. + expect(claudeRateLimitEventToUpdate(event, noNames)).toBeUndefined(); + expect(claudeRateLimitEventToUpdate(event, { overageIncluded: "Fable" })).toEqual({ + windows: [ + { + id: "seven_day_fable", + kind: "weekly", + label: "Weekly · Fable", + usedPercent: 40, + windowDurationMins: 10080, + }, + ], + }); + }); + + it("ignores windows the page does not render and events without a utilization", () => { + expect( + claudeRateLimitEventToUpdate( + { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 }, + noNames, + ), + ).toBeUndefined(); + expect( + claudeRateLimitEventToUpdate({ status: "rejected", rateLimitType: "five_hour" }, noNames), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fae307afb563a9dd48256a020521e914a607ae0 --- /dev/null +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -0,0 +1,199 @@ +/** + * Claude Code subscription usage. Both sources produce windows with the same + * ids so a turn-driven `rate_limit_event` lands on the row the SDK's + * `get_usage` read established: + * + * - `get_usage` (on demand, during the capabilities probe) reports every + * window at once as 0–100 percentages with ISO reset times. + * - `rate_limit_event` (streamed during a turn) names one window at a time + * with a 0–1 utilization fraction and an epoch-seconds reset. + * + * @module provider/claudeUsageLimits + */ +import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk"; +import type { + ProviderUsageLimitsUpdate, + ServerProviderUsageLimits, + ServerProviderUsageWindow, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "./providerUsageLimits.ts"; + +const SESSION_MINS = 5 * 60; +const WEEK_MINS = 7 * 24 * 60; + +/** + * The account-wide windows, keyed by the SDK's `rateLimitType`. Model-scoped + * weeklies are additive on top of these: the CLI reports them under + * `rate_limits.model_scoped[]` on `get_usage` and streams the overage-included + * model bucket (Fable today) as `seven_day_overage_included`. + */ +const WINDOWS: Readonly< + Record> +> = { + five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS }, + seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS }, +}; + +/** + * The streamed event names the overage-included bucket by type + * (`seven_day_overage_included`), while `get_usage` names it by the model's + * `display_name`. Which model that is changes over time, so the probe records + * the name it saw and the event mapper reuses it; the mid-turn update then + * lands on the row the probe drew instead of opening a second one. + */ +const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; + +export interface ClaudeScopedLimitNames { + readonly overageIncluded: string | undefined; +} + +export const makeClaudeScopedLimitNames = Ref.make({ + overageIncluded: undefined, +}); + +function scopedWindowId(displayName: string): string { + return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`; +} + +function scopedWindow( + displayName: string, + usedPercent: number, + resetsAt: string | undefined, +): ServerProviderUsageWindow { + return { + id: scopedWindowId(displayName), + kind: "weekly", + label: `Weekly · ${displayName}`, + windowDurationMins: WEEK_MINS, + usedPercent: clampPercent(usedPercent), + ...(resetsAt ? { resetsAt } : {}), + }; +} + +/** + * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is + * read structurally until the `.d.ts` catches up. + */ +interface ModelScopedWindow { + readonly display_name: string; + readonly utilization: number | null; + readonly resets_at: string | null; +} + +function readModelScoped(rateLimits: object): ReadonlyArray { + const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped; + if (!Array.isArray(raw)) return []; + return raw.filter( + (entry): entry is ModelScopedWindow => + typeof entry === "object" && + entry !== null && + typeof (entry as ModelScopedWindow).display_name === "string", + ); +} + +function isoFromEpochSeconds(value: number | undefined): string | undefined { + if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined; + const dt = DateTime.make(value * 1000); + return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; +} + +function isoFromString(value: string | null | undefined): string | undefined { + if (!value) return undefined; + const dt = DateTime.make(value); + return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; +} + +function makeWindow( + id: keyof typeof WINDOWS & string, + usedPercent: number, + resetsAt: string | undefined, +): ServerProviderUsageWindow { + const window = WINDOWS[id]!; + return { + id, + ...window, + usedPercent: clampPercent(usedPercent), + ...(resetsAt ? { resetsAt } : {}), + }; +} + +/** + * Utilization is a 0–1 fraction on the streamed event. An overage-included + * event before any probe has named the bucket is dropped: guessing a name + * would draw a row the next probe cannot reconcile. + */ +export function claudeRateLimitEventToUpdate( + info: SDKRateLimitInfo, + names: ClaudeScopedLimitNames, +): ProviderUsageLimitsUpdate | undefined { + const type: string | undefined = info.rateLimitType; + if (!type || typeof info.utilization !== "number") { + return undefined; + } + const usedPercent = info.utilization * 100; + const resetsAt = isoFromEpochSeconds(info.resetsAt); + if (type in WINDOWS) { + return { windows: [makeWindow(type, usedPercent, resetsAt)] }; + } + if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { + return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] }; + } + return undefined; +} + +/** + * Percentages on the `get_usage` response are already 0–100. Also yields the + * scoped-bucket names the response carried, for the event mapper to reuse. + */ +export function claudeUsageResponseToLimits(input: { + readonly response: Pick; + readonly checkedAt: string; +}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } { + const { response, checkedAt } = input; + if (!response.rate_limits_available || !response.rate_limits) { + return { + limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), + names: { overageIncluded: undefined }, + }; + } + const windows: ServerProviderUsageWindow[] = []; + for (const id of Object.keys(WINDOWS)) { + const window = response.rate_limits[id as "five_hour" | "seven_day"]; + if (!window || typeof window.utilization !== "number") continue; + windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at))); + } + // The CLI filters `model_scoped` to the overage-included allowlist, which + // today holds one model; the first entry is the one the event refers to. + let overageIncluded: string | undefined; + for (const entry of readModelScoped(response.rate_limits)) { + if (typeof entry.utilization !== "number") continue; + windows.push( + scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)), + ); + // Only a bucket that drew a row may receive events; naming one that was + // skipped would let a mid-turn event open a row the probe never showed. + overageIncluded ??= entry.display_name; + } + return { + limits: makeUsageLimits({ checkedAt, windows }), + names: { overageIncluded }, + }; +} + +/** Probe-side helper: map the response and remember the scoped names for events. */ +export const recordClaudeUsageResponse = ( + namesRef: Ref.Ref, + input: Parameters[0], +): Effect.Effect => { + const { limits, names } = claudeUsageResponseToLimits(input); + return Ref.set(namesRef, names).pipe(Effect.as(limits)); +}; diff --git a/apps/server/src/provider/codexLaunchArgs.test.ts b/apps/server/src/provider/codexLaunchArgs.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..115ac28eaf94586f4580ca6eece0789dbe6772d9 --- /dev/null +++ b/apps/server/src/provider/codexLaunchArgs.test.ts @@ -0,0 +1,59 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { + codexAppServerArgs, + codexExecLaunchArgs, + resolveCodexLaunchArgs, +} from "./codexLaunchArgs.ts"; + +describe("resolveCodexLaunchArgs", () => { + it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => { + NodeAssert.equal( + resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }), + "--enable foo", + ); + }); + + it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => { + NodeAssert.equal( + resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }), + "--strict-config", + ); + }); + + it("ignores whitespace-only environment values", () => { + NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), ""); + }); +}); + +describe("codexAppServerArgs", () => { + it("returns the app-server command for empty launch args", () => { + NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]); + }); + + it("appends parsed launch args after app-server", () => { + NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [ + "app-server", + "--strict-config", + "--enable", + "foo", + ]); + }); +}); + +describe("codexExecLaunchArgs", () => { + it("keeps shared codex flags and omits app-server-only flags", () => { + NodeAssert.deepStrictEqual( + codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'), + ["--strict-config", "--enable", "foo", "--config", "model=gpt 5"], + ); + }); + + it("does not pair value-taking flags with adjacent flags", () => { + NodeAssert.deepStrictEqual(codexExecLaunchArgs("--config --strict-config --enable --disable"), [ + "--strict-config", + ]); + }); +}); diff --git a/apps/server/src/provider/codexLaunchArgs.ts b/apps/server/src/provider/codexLaunchArgs.ts new file mode 100644 index 0000000000000000000000000000000000000000..33def5ce9a61a90e851160fe2ad358140c60cba1 --- /dev/null +++ b/apps/server/src/provider/codexLaunchArgs.ts @@ -0,0 +1,39 @@ +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; + +const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; + +export const resolveCodexLaunchArgs = ( + launchArgs?: string, + environment: NodeJS.ProcessEnv = process.env, +) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; + +const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => tokenizeCliArgs(launchArgs); + +export const codexAppServerArgs = (launchArgs?: string) => [ + "app-server", + ...codexLaunchArgv(launchArgs), +]; + +export const codexExecLaunchArgs = (launchArgs?: string) => { + const args = codexLaunchArgv(launchArgs); + const execArgs: Array = []; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === undefined) continue; + + if (arg === "--strict-config" || arg.startsWith("--config=") || arg.startsWith("-c=")) { + execArgs.push(arg); + } else if (arg === "--config" || arg === "-c" || arg === "--enable" || arg === "--disable") { + const value = args[index + 1]; + if (value !== undefined && !value.startsWith("-")) { + execArgs.push(arg, value); + index++; + } + } else if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) { + execArgs.push(arg); + } + } + + return execArgs; +}; diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d310a9fff730ba750d51ac21e5b1a74b5a8bc18 --- /dev/null +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -0,0 +1,316 @@ +import * as CodexErrors from "effect-codex-app-server/errors"; +import { describe, expect, it } from "vite-plus/test"; + +import { + codexRateLimitsFailureMessage, + codexRateLimitsToLimits, + codexRateLimitsToUpdate, + codexResetCreditsToContract, + codexUsageLimitMessage, + mergeCodexRateLimits, +} from "./codexUsageLimits.ts"; + +const checkedAt = "2026-07-18T10:00:00.000Z"; + +describe("codexRateLimitsToLimits", () => { + it("maps primary and secondary onto the session and weekly windows", () => { + expect( + codexRateLimitsToLimits({ + checkedAt, + snapshot: { + planType: "plus", + primary: { usedPercent: 12, resetsAt: 1_784_000_000, windowDurationMins: 300 }, + secondary: { usedPercent: 47, resetsAt: 1_784_500_000, windowDurationMins: 10080 }, + }, + }), + ).toEqual({ + checkedAt, + windows: [ + { + id: "primary", + kind: "session", + label: "Session", + usedPercent: 12, + windowDurationMins: 300, + resetsAt: "2026-07-14T03:33:20.000Z", + }, + { + id: "secondary", + kind: "weekly", + label: "Weekly", + usedPercent: 47, + windowDurationMins: 10080, + resetsAt: "2026-07-19T22:26:40.000Z", + }, + ], + }); + }); + + it("treats a lone duration-less primary as monthly on Free and Go", () => { + expect( + codexRateLimitsToLimits({ + checkedAt, + snapshot: { planType: "free", primary: { usedPercent: 80, resetsAt: null } }, + }).windows, + ).toEqual([ + { + id: "primary", + kind: "monthly", + label: "Monthly", + usedPercent: 80, + windowDurationMins: 43_200, + }, + ]); + }); + + it("selects the main Codex allowance and leaves Spark out", () => { + const spark = { + limitId: "codex_bengalfox", + primary: { usedPercent: 0, windowDurationMins: 300 }, + secondary: { usedPercent: 90, windowDurationMins: 10080 }, + }; + expect( + codexRateLimitsToLimits({ + checkedAt, + snapshot: spark, + rateLimitsByLimitId: { + codex_bengalfox: spark, + codex: { secondary: { usedPercent: 42, windowDurationMins: 10080 } }, + }, + }).windows, + ).toEqual([ + { + id: "secondary", + kind: "weekly", + label: "Weekly", + usedPercent: 42, + windowDurationMins: 10080, + }, + ]); + }); + + it.each([undefined, null, {}])( + "supports legacy reads with no bucket map: %j", + (rateLimitsByLimitId) => { + const snapshot = { primary: { usedPercent: 12, windowDurationMins: 300 } }; + expect(codexRateLimitsToLimits({ checkedAt, snapshot, rateLimitsByLimitId })).toEqual( + codexRateLimitsToLimits({ checkedAt, snapshot }), + ); + }, + ); + + it("does not show a model-specific legacy snapshot as the main allowance", () => { + expect( + codexRateLimitsToLimits({ + checkedAt, + snapshot: { + limitId: "codex_bengalfox", + secondary: { usedPercent: 90 }, + }, + }).windows, + ).toEqual([]); + }); +}); + +describe("codexRateLimitsToUpdate", () => { + it("carries only the windows the notification names", () => { + expect( + codexRateLimitsToUpdate({ + secondary: { usedPercent: 51, windowDurationMins: 10080 }, + }), + ).toEqual({ + windows: [ + { + id: "secondary", + kind: "weekly", + label: "Weekly", + usedPercent: 51, + windowDurationMins: 10080, + }, + ], + }); + expect(codexRateLimitsToUpdate({ planType: "plus" })).toBeUndefined(); + }); + + it("ignores Spark notifications so they cannot overwrite the main allowance", () => { + expect( + codexRateLimitsToUpdate({ + limitId: "codex_bengalfox", + primary: { usedPercent: 0, windowDurationMins: 300 }, + secondary: { usedPercent: 90, windowDurationMins: 10080 }, + }), + ).toBeUndefined(); + expect( + codexRateLimitsToUpdate({ + limitId: "codex", + secondary: { usedPercent: 42, windowDurationMins: 10080 }, + })?.windows, + ).toEqual([ + { + id: "secondary", + kind: "weekly", + label: "Weekly", + usedPercent: 42, + windowDurationMins: 10080, + }, + ]); + }); +}); + +describe("codexRateLimitsFailureMessage", () => { + it("keeps the JSON-RPC code and nothing else from a request failure", () => { + expect( + codexRateLimitsFailureMessage( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: + "failed to fetch codex rate limits: GET https://chatgpt.com/backend-api/wham/usage failed: 401 Unauthorized", + }), + ), + ).toBe("Codex could not read usage (JSON-RPC -32603)."); + }); + + it("phrases a dead process differently from a bad answer", () => { + expect( + codexRateLimitsFailureMessage(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })), + ).toBe("Codex exited before it could report usage."); + }); +}); + +describe("codexResetCreditsToContract", () => { + it("counts available credits and reports the soonest expiry", () => { + expect( + codexResetCreditsToContract({ + availableCount: 2, + credits: [ + { status: "available", expiresAt: 1_784_500_000 }, + { status: "redeemed", expiresAt: 1_700_000_000 }, + { status: "available", expiresAt: 1_784_000_000 }, + ], + }), + ).toEqual({ availableCount: 2, nextExpiresAt: "2026-07-14T03:33:20.000Z" }); + expect(codexResetCreditsToContract({ availableCount: 0 })).toEqual({ availableCount: 0 }); + expect(codexResetCreditsToContract(null)).toBeUndefined(); + }); + + it("rides along on the probe's limits", () => { + expect( + codexRateLimitsToLimits({ + checkedAt, + snapshot: { primary: { usedPercent: 5, windowDurationMins: 300 } }, + resetCredits: { availableCount: 1 }, + }).resetCredits, + ).toEqual({ availableCount: 1 }); + }); +}); + +describe("codexUsageLimitMessage", () => { + const at = "2026-01-01T00:00:00.000Z"; + const atSeconds = Date.parse(at) / 1000; + + it("names the exhausted window and the workspace's missing credits", () => { + expect( + codexUsageLimitMessage( + { + limitId: "codex", + rateLimitReachedType: "workspace_owner_credits_depleted", + primary: { usedPercent: 40, resetsAt: atSeconds + 3_600, windowDurationMins: 300 }, + secondary: { + usedPercent: 100, + resetsAt: atSeconds + 5 * 86_400 + 5 * 3_600, + windowDurationMins: 10_080, + }, + }, + at, + ), + ).toBe( + "Codex usage limit reached. The weekly limit resets in 5d 5h. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.", + ); + }); + + it("points a reached spend cap at the workspace owner", () => { + expect( + codexUsageLimitMessage( + { + limitId: "codex", + rateLimitReachedType: "workspace_member_usage_limit_reached", + primary: { + usedPercent: 100, + resetsAt: atSeconds + 3 * 3_600 + 20 * 60, + windowDurationMins: 300, + }, + }, + at, + ), + ).toBe( + "Codex usage limit reached. The session limit resets in 3h 20m. The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.", + ); + }); + + it("names no window when credits run out without one", () => { + expect( + codexUsageLimitMessage( + { limitId: "codex", rateLimitReachedType: "workspace_member_credits_depleted" }, + at, + ), + ).toBe( + "Codex usage limit reached. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.", + ); + }); + + it("says only what it knows without a snapshot", () => { + expect(codexUsageLimitMessage(undefined, at)).toBe( + "Codex usage limit reached. Send the message again once the limit resets.", + ); + }); +}); + +describe("mergeCodexRateLimits", () => { + it("keeps windows an update does not carry", () => { + const merged = mergeCodexRateLimits( + { + limitId: "codex", + planType: "business", + primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, + }, + { rateLimitReachedType: "rate_limit_reached" }, + ); + + expect(merged).toEqual({ + limitId: "codex", + planType: "business", + rateLimitReachedType: "rate_limit_reached", + primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, + }); + }); + + it("ignores a model-specific snapshot so it cannot replace the main allowance", () => { + const main = { + limitId: "codex", + primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, + }; + expect( + mergeCodexRateLimits(main, { + limitId: "spark", + primary: { usedPercent: 3, resetsAt: 1_800_000_000, windowDurationMins: 300 }, + }), + ).toBe(main); + }); +}); + +import { codexUsageLimitResetAt } from "./codexUsageLimits.ts"; + +describe("codexUsageLimitResetAt", () => { + it("waits for every exhausted window and never invents an unknown reset", () => { + expect( + codexUsageLimitResetAt({ + primary: { usedPercent: 100, resetsAt: 2000000000 }, + secondary: { usedPercent: 100, resetsAt: 2000100000 }, + }), + ).toBe("2033-05-19T07:20:00.000Z"); + expect(codexUsageLimitResetAt({ primary: { usedPercent: 100 } })).toBeNull(); + expect( + codexUsageLimitResetAt({ primary: { usedPercent: 50, resetsAt: 2000000000 } }), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts new file mode 100644 index 0000000000000000000000000000000000000000..32a7a1f07a9cf766a01755c2182a879e49754801 --- /dev/null +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -0,0 +1,250 @@ +/** + * Codex subscription usage. The `account/rateLimits/read` response and the + * `account/rateLimits/updated` notification carry the same snapshot shape, so + * one mapper serves the status probe and the turn-driven update; both emit + * windows with the same ids so they merge onto the same rows. + * + * @module provider/codexUsageLimits + */ +import type { + ProviderUsageLimitsUpdate, + ServerProviderResetCredits, + ServerProviderUsageLimits, + ServerProviderUsageWindow, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import type * as CodexErrors from "effect-codex-app-server/errors"; + +import { clampPercent, makeUsageLimits } from "./providerUsageLimits.ts"; + +interface CodexRateLimitWindow { + readonly usedPercent: number; + readonly resetsAt?: number | null; + readonly windowDurationMins?: number | null; +} + +/** Structural view of the generated `RateLimitSnapshot`; both messages satisfy it. */ +export interface CodexRateLimitSnapshot { + readonly limitId?: string | null; + readonly planType?: string | null; + readonly rateLimitReachedType?: string | null; + readonly primary?: CodexRateLimitWindow | null; + readonly secondary?: CodexRateLimitWindow | null; +} + +/** Structural view of the read response's `rateLimitResetCredits`. */ +export interface CodexResetCreditsSummary { + readonly availableCount: number; + readonly credits?: ReadonlyArray<{ + readonly status: string; + readonly expiresAt?: number | null; + }> | null; +} + +const SESSION_MINS = 5 * 60; +const WEEK_MINS = 7 * 24 * 60; +const MONTH_MINS = 30 * 24 * 60; + +function isoFromEpochSeconds(value: number | null | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; + const dt = DateTime.make(value * 1000); + return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; +} + +function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] { + if (mins >= MONTH_MINS) return "monthly"; + if (mins >= WEEK_MINS) return "weekly"; + return "session"; +} + +function labelForKind(kind: ServerProviderUsageWindow["kind"]): string { + return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly"; +} + +/** + * `primary` / `secondary` are positions, not durations. Codex usually sends + * `windowDurationMins`; when it does not, paid plans expose the 5-hour and + * weekly pair and Free/Go expose one monthly allowance. + */ +function codexRateLimitsToWindows( + snapshot: CodexRateLimitSnapshot, +): ReadonlyArray { + // Show the main allowance only. Model-specific notifications (such as Spark) + // must not replace its primary/secondary rows. Older CLIs omit the limit id. + if (snapshot.limitId && snapshot.limitId !== "codex") return []; + const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go"; + const positions = [ + ["primary", snapshot.primary, isMonthlyPlan ? MONTH_MINS : SESSION_MINS], + ["secondary", snapshot.secondary, WEEK_MINS], + ] as const; + const windows: ServerProviderUsageWindow[] = []; + for (const [id, window, fallbackMins] of positions) { + if (!window || !Number.isFinite(window.usedPercent)) continue; + const windowDurationMins = + typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins; + const kind = kindForDuration(windowDurationMins); + const resetsAt = isoFromEpochSeconds(window.resetsAt); + windows.push({ + id, + kind, + label: labelForKind(kind), + usedPercent: clampPercent(window.usedPercent), + windowDurationMins, + ...(resetsAt ? { resetsAt } : {}), + }); + } + return windows; +} + +export function codexResetCreditsToContract( + summary: CodexResetCreditsSummary | null | undefined, +): ServerProviderResetCredits | undefined { + if (!summary) return undefined; + const expiries = (summary.credits ?? []) + .filter((credit) => credit.status === "available") + .map((credit) => credit.expiresAt) + .filter((value): value is number => typeof value === "number"); + const nextExpiresAt = + expiries.length > 0 ? isoFromEpochSeconds(Math.min(...expiries)) : undefined; + return { + availableCount: Math.max(0, summary.availableCount), + ...(nextExpiresAt ? { nextExpiresAt } : {}), + }; +} + +export function codexRateLimitsToLimits(input: { + readonly snapshot: CodexRateLimitSnapshot; + readonly rateLimitsByLimitId?: + | Readonly> + | null + | undefined; + readonly resetCredits?: CodexResetCreditsSummary | null | undefined; + readonly checkedAt: string; +}): ServerProviderUsageLimits { + const resetCredits = codexResetCreditsToContract(input.resetCredits); + // Select the main bucket explicitly; the legacy snapshot can name another limit. + const windows = codexRateLimitsToWindows(input.rateLimitsByLimitId?.codex ?? input.snapshot); + return { + ...makeUsageLimits({ + checkedAt: input.checkedAt, + windows, + }), + ...(resetCredits ? { resetCredits } : {}), + }; +} + +export function codexRateLimitsToUpdate( + snapshot: CodexRateLimitSnapshot, +): ProviderUsageLimitsUpdate | undefined { + const windows = codexRateLimitsToWindows(snapshot); + return windows.length > 0 ? { windows } : undefined; +} + +/** + * A bounded, client-safe reason for a failed `account/rateLimits/read`. The + * raw error is for the log; only the category and, for a JSON-RPC failure, + * the code reach the Limits view. + */ +export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerError): string { + switch (error._tag) { + case "CodexAppServerRequestError": + return `Codex could not read usage (JSON-RPC ${error.code}).`; + case "CodexAppServerSpawnError": + return "Codex could not be started to read usage."; + case "CodexAppServerProcessExitedError": + return "Codex exited before it could report usage."; + default: + return "Codex did not answer the usage request."; + } +} + +/** + * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a + * field the update omits keeps the value observed earlier in the session, so a + * later notification that only names the limit it reached must not drop the + * windows an earlier one carried. + */ +export function mergeCodexRateLimits( + previous: CodexRateLimitSnapshot | undefined, + update: CodexRateLimitSnapshot, +): CodexRateLimitSnapshot | undefined { + // Model-specific snapshots (such as Spark) describe a different allowance + // and must not overwrite the main one, the same rule the usage rows apply. + if (update.limitId && update.limitId !== "codex") return previous; + if (!previous) return update; + return { + ...previous, + ...(update.limitId !== undefined ? { limitId: update.limitId } : {}), + ...(update.planType !== undefined ? { planType: update.planType } : {}), + ...(update.rateLimitReachedType !== undefined + ? { rateLimitReachedType: update.rateLimitReachedType } + : {}), + ...(update.primary !== undefined ? { primary: update.primary } : {}), + ...(update.secondary !== undefined ? { secondary: update.secondary } : {}), + }; +} + +/** Coarse remaining wait, matching how the usage rows read: `5d 5h`, `3h 20m`, `12m`. */ +function formatCodexUsageLimitWait(waitMs: number): string { + const totalMinutes = Math.ceil(waitMs / 60_000); + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + if (days > 0) return hours === 0 ? `${days}d` : `${days}d ${hours}h`; + if (hours === 0) return `${totalMinutes}m`; + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; +} + +function codexUsageLimitNextStep(rateLimitReachedType: string | null | undefined): string { + switch (rateLimitReachedType) { + case "workspace_owner_credits_depleted": + case "workspace_member_credits_depleted": + return " The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets."; + case "workspace_owner_usage_limit_reached": + case "workspace_member_usage_limit_reached": + return " The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets."; + default: + return " Send the message again once the limit resets."; + } +} + +/** + * The message a usage-limit stop shows instead of the provider sentence, which + * on a Business workspace blames credits for a window that simply ran out. The + * window named is the exhausted one that has yet to reset, latest first; `atIso` + * is the stopping event's timestamp, not the wall clock. + */ +export function codexUsageLimitMessage( + snapshot: CodexRateLimitSnapshot | undefined, + atIso: string, +): string { + const atMs = Date.parse(atIso); + const windows = snapshot && Number.isFinite(atMs) ? codexRateLimitsToWindows(snapshot) : []; + let reset = ""; + let latestResetMs = Number.NEGATIVE_INFINITY; + for (const window of windows) { + if (window.usedPercent < 100 || !window.resetsAt) continue; + const resetMs = Date.parse(window.resetsAt); + if (!Number.isFinite(resetMs) || resetMs <= atMs || resetMs <= latestResetMs) continue; + latestResetMs = resetMs; + reset = ` The ${window.kind} limit resets in ${formatCodexUsageLimitWait(resetMs - atMs)}.`; + } + return `Codex usage limit reached.${reset}${codexUsageLimitNextStep(snapshot?.rateLimitReachedType)}`; +} + +/** All exhausted windows must reset before a continuation can run. */ +export function codexUsageLimitResetAt( + snapshot: CodexRateLimitSnapshot | undefined, +): string | null { + if (!snapshot) return null; + const windows = codexRateLimitsToWindows(snapshot).filter((window) => window.usedPercent >= 100); + if (windows.length === 0 || windows.some((window) => !window.resetsAt)) return null; + return windows.reduce( + (latest, window) => + latest === null || Date.parse(window.resetsAt!) > Date.parse(latest) + ? window.resetsAt! + : latest, + null, + ); +} diff --git a/apps/server/src/provider/cursorUsageLimits.test.ts b/apps/server/src/provider/cursorUsageLimits.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ce2cee7fe8c2f4eb9cb9aeebf786219284bdbe6 --- /dev/null +++ b/apps/server/src/provider/cursorUsageLimits.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts"; + +const withNodeServices = (effect: Effect.Effect) => + effect.pipe(Effect.provide(NodeServices.layer)); + +describe("Cursor usage limits", () => { + const checkedAt = "2026-09-16T00:00:00.000Z"; + + it("uses the advertised percentages and billing-cycle reset", () => { + const limits = cursorUsageResponseToLimits( + { + billingCycleEnd: "1789876386000", + planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 }, + }, + checkedAt, + ); + expect(limits.windows).toEqual( + expect.arrayContaining([ + { + id: "totalPercentUsed", + kind: "monthly", + label: "Overall", + usedPercent: 72.4, + resetsAt: "2026-09-20T03:53:06.000Z", + }, + { + id: "autoPercentUsed", + kind: "monthly", + label: "Cursor Models", + usedPercent: 69.5, + resetsAt: "2026-09-20T03:53:06.000Z", + }, + { + id: "apiPercentUsed", + kind: "monthly", + label: "Other Models", + usedPercent: 100, + resetsAt: "2026-09-20T03:53:06.000Z", + }, + ]), + ); + }); + + it("does not invent unused allowance for absent buckets", () => { + expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( + "unsupported", + ); + expect( + cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); + expect( + cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); + }); + + it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => + Effect.gen(function* () { + yield* withNodeServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(path.join(directory, "cursor")); + yield* fs.writeFileString( + path.join(directory, "cursor", "auth.json"), + '{"accessToken":"instance-token"}', + ); + const client = HttpClient.make((request) => { + expect(request.url).toBe( + "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + ); + expect(request.method).toBe("POST"); + expect(request.headers.authorization).toBe("Bearer instance-token"); + expect(request.headers["connect-protocol-version"]).toBe("1"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), + ), + ); + }); + yield* fs.makeDirectory(path.join(directory, ".cursor")); + yield* fs.writeFileString( + path.join(directory, ".cursor", "auth.json"), + '{"accessToken":"instance-token"}', + ); + for (const platform of ["linux", "darwin"] as const) { + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "https://cursor.example/" }, + { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, + ).pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HttpClient.HttpClient, client), + ); + expect(limits.windows[0]?.usedPercent).toBe(42); + } + }).pipe(Effect.scoped), + ); + }), + ); + + it.effect( + "never reads stale files for keychain or memory logins, but accepts an explicit auth token", + () => + Effect.gen(function* () { + for (const platform of ["linux", "darwin"] as const) { + for (const token of [undefined, "explicit-token"]) { + const limits = yield* withNodeServices( + readCursorUsageLimits( + { apiEndpoint: "" }, + { + AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", + ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), + }, + false, + async () => { + throw new Error("must not read Keychain before opt-in"); + }, + ).pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("must not read an unrelated credential file"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(token).toBe("explicit-token"); + expect(request.headers.authorization).toBe("Bearer explicit-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ planUsage: { totalPercentUsed: 10 } }), + ), + ); + }), + ), + ), + ); + if (token) expect(limits.windows[0]?.usedPercent).toBe(10); + else expect(limits.unavailable?.reason).toBe("unsupported"); + } + } + }), + ); + + it.effect("reports failed requests without exposing credentials or response bodies", () => + Effect.gen(function* () { + const limits = yield* withNodeServices( + readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("private response", { status: 401 }), + ), + ), + ), + ), + ), + ); + expect(limits.unavailable).toEqual({ + reason: "probeFailed", + message: "Cursor could not read usage limits.", + }); + }), + ); + + it.effect("does not use a stored login for an explicit API key", () => + Effect.gen(function* () { + const limits = yield* withNodeServices( + readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request usage")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("unsupported"); + }), + ); + + it.effect("reads the default macOS Cursor login from Keychain for limits", () => + Effect.gen(function* () { + const limits = yield* withNodeServices( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("must not read a stale credential file"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.headers.authorization).toBe("Bearer keychain-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ planUsage: { totalPercentUsed: 42 } }), + ), + ); + }), + ), + ), + ); + expect(limits.windows[0]?.usedPercent).toBe(42); + }), + ); + + it.effect("reports a Keychain initialization failure without failing the provider refresh", () => + Effect.gen(function* () { + const limits = yield* withNodeServices( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { + throw new Error("Keychain initialization failed"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request limits without a login")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("probeFailed"); + }), + ); + + it.effect("does not read Keychain or send its token to a custom endpoint", () => + Effect.gen(function* () { + for (const [apiEndpoint, environment] of [ + ["http://localhost:3000", {}], + ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], + ["https://cursor-proxy.example", {}], + ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], + ] as const) { + const limits = yield* withNodeServices( + readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { + throw new Error("must not read Keychain for a custom endpoint"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("unsupported"); + expect(limits.unavailable?.message).toContain("default Cursor endpoint"); + } + }), + ); +}); diff --git a/apps/server/src/provider/cursorUsageLimits.ts b/apps/server/src/provider/cursorUsageLimits.ts new file mode 100644 index 0000000000000000000000000000000000000000..eeacd46a7d79275920726d7d83f39da7e53ed8d4 --- /dev/null +++ b/apps/server/src/provider/cursorUsageLimits.ts @@ -0,0 +1,151 @@ +import * as NodeOS from "node:os"; +import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "./providerUsageLimits.ts"; +import { readMacCursorAccessToken } from "./cursorKeychainToken.ts"; + +const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); +const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; +const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); +const CursorUsageResponse = Schema.Struct({ + billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + planUsage: Schema.optional( + Schema.Struct({ + totalPercentUsed: Schema.optional(Schema.Number), + autoPercentUsed: Schema.optional(Schema.Number), + apiPercentUsed: Schema.optional(Schema.Number), + }), + ), +}); + +/** Cursor's dashboard percentages include bonus usage; spend / limit does not. */ +export function cursorUsageResponseToLimits( + response: typeof CursorUsageResponse.Type, + checkedAt: string, +) { + const reset = DateTime.make(Number(response.billingCycleEnd)); + const resetsAt = + Number(response.billingCycleEnd) > 0 && Option.isSome(reset) + ? DateTime.formatIso(reset.value) + : undefined; + const windows: ServerProviderUsageWindow[] = []; + if (response.planUsage) { + for (const { id, label } of CURSOR_USAGE_WINDOWS) { + const usedPercent = response.planUsage[id]; + if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; + windows.push({ + id, + kind: "monthly", + label, + usedPercent: clampPercent(usedPercent), + ...(resetsAt ? { resetsAt } : {}), + }); + } + } + return windows.length > 0 + ? makeUsageLimits({ checkedAt, windows }) + : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); +} + +export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( + settings: Pick, + environment: NodeJS.ProcessEnv = process.env, + allowKeychain = false, + keychainToken: () => Promise = readMacCursorAccessToken, +) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const endpoint = ( + settings.apiEndpoint?.trim() || + environment.CURSOR_API_ENDPOINT?.trim() || + DEFAULT_CURSOR_API_ENDPOINT + ).replace(/\/$/, ""); + let token = environment.CURSOR_AUTH_TOKEN?.trim(); + // An explicit API key can name a different account from the stored login. + if (!token && environment.CURSOR_API_KEY?.trim()) { + return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + } + const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; + if (!token && credentialStore === "memory") { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", + }); + } + if (!token && platform === "darwin" && credentialStore !== "file") { + if (!allowKeychain) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Enable Cursor account usage in T3 Code to read its Keychain login.", + }); + } + if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", + }); + } + token = (yield* Effect.tryPromise(keychainToken))?.trim(); + } else if (!token) { + const home = + (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); + const directory = + platform === "win32" + ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") + : platform === "darwin" + ? path.join(home, ".cursor") + : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); + const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + Effect.flatMap(decodeCredentials), + ); + token = credentials.accessToken?.trim(); + } + if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( + HttpClientRequest.bearerToken(token), + HttpClientRequest.setHeaders({ + "connect-protocol-version": "1", + "x-cursor-client-type": "cli", + }), + HttpClientRequest.bodyJsonUnsafe({}), + ), + ); + const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( + yield* HttpClientResponse.filterStatusOk(response), + ); + return cursorUsageResponseToLimits(body, checkedAt); + }).pipe( + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => + makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Cursor could not read usage limits.", + }), + ), + ); +}); diff --git a/apps/server/src/provider/grokUsageLimits.ts b/apps/server/src/provider/grokUsageLimits.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0eaa6dc51f2b1a162ba5b877742dfdfc0c61a29 --- /dev/null +++ b/apps/server/src/provider/grokUsageLimits.ts @@ -0,0 +1,175 @@ +import * as NodeOS from "node:os"; +import type { ServerProviderUsageWindow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "./providerUsageLimits.ts"; + +const GrokCredentials = Schema.Record( + Schema.String, + Schema.Struct({ + key: Schema.optional(Schema.String), + auth_mode: Schema.optional(Schema.String), + email: Schema.optional(Schema.String), + }), +); +const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); +const GrokUsageResponse = Schema.Struct({ + config: Schema.optional( + Schema.Struct({ + creditUsagePercent: Schema.optional(Schema.Number), + currentPeriod: Schema.optional( + Schema.Struct({ + type: Schema.optional(Schema.String), + end: Schema.optional(Schema.String), + }), + ), + }), + ), +}); + +export function grokUsageResponseToLimits( + response: typeof GrokUsageResponse.Type, + checkedAt: string, +) { + const usedPercent = response.config?.creditUsagePercent; + if (usedPercent === undefined || !Number.isFinite(usedPercent)) { + // A billing read that succeeded but carries no percentage is an account + // with nothing metered yet, not one that can never report: xAI omits the + // field entirely (rather than sending 0) until usage registers, then fills + // it in. Calling that `unsupported` would strand the account — the Limits + // view drops unsupported entries and deliberately mutes their notice, so a + // freshly signed-in Grok account would vanish with no explanation until it + // happened to be used, and `applyUsageLimitsUpdate` would refuse the + // mid-turn windows that could have recovered it. + return makeUsageLimits({ checkedAt, windows: [] }); + } + const period = response.config?.currentPeriod; + const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, ""); + const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other"; + const reset = period?.end ? DateTime.make(period.end) : Option.none(); + const window: ServerProviderUsageWindow = { + id: "subscription", + kind, + label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription", + usedPercent: clampPercent(usedPercent), + ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}), + }; + return makeUsageLimits({ checkedAt, windows: [window] }); +} + +/** + * The grok.com login the CLI uses by default, or undefined when the CLI is + * configured to pick another account, endpoint, or an API key. + */ +const readGrokCredential = Effect.fn("readGrokCredential")(function* ( + environment: NodeJS.ProcessEnv, +) { + // T3's ACP adapter explicitly selects API-key auth when this variable is set. + if (environment.XAI_API_KEY?.trim()) return undefined; + // Alternate auth deployments can select another scope or account from the same file. + if ( + [ + "GROK_OIDC_ISSUER", + "GROK_OIDC_CLIENT_ID", + "GROK_OAUTH2_ISSUER", + "GROK_OAUTH2_CLIENT_ID", + "GROK_OAUTH2_PRINCIPAL_TYPE", + "GROK_OAUTH2_PRINCIPAL_ID", + "GROK_AUTH_PROVIDER_COMMAND", + "GROK_LOCAL_AUTH", + "GROK_CLI_CHAT_PROXY_BASE_URL", + "GROK_MODELS_BASE_URL", + "GROK_CONFIG", + "GROK_CONFIG_PATH", + ].some((name) => environment[name]?.trim()) + ) { + return undefined; + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = + environment.GROK_HOME?.trim() || + path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); + for (const configPath of [ + path.join(home, "config.toml"), + path.join(home, "managed_config.toml"), + path.join(home, "requirements.toml"), + "/etc/grok/managed_config.toml", + "/etc/grok/requirements.toml", + ]) { + const config = yield* fs.readFileString(configPath).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), + }), + ); + // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. + if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { + return undefined; + } + } + const contents = + environment.GROK_AUTH?.trim() || + (yield* fs.readFileString(path.join(home, "auth.json")).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + )); + const credentials = yield* decodeCredentials(contents); + // Never pick an arbitrary account from other deployments stored in the same file. + const credential = + credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? + credentials["https://accounts.x.ai/sign-in"]; + return credential?.auth_mode === "api_key" ? undefined : credential; +}); + +/** + * Reads the default grok.com login once and reports its usage limits along with + * its email, so the email always names the account whose quota was read. + */ +export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( + environment: NodeJS.ProcessEnv = process.env, +) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const probeFailed = makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Grok could not read usage limits.", + }); + const credential = yield* Effect.option( + readGrokCredential(environment).pipe(Effect.timeout("10 seconds")), + ); + if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed }; + const email = credential.value?.email?.trim() || undefined; + const token = credential.value?.key?.trim(); + if (!token) { + return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }; + } + // A failed quota request still knows which account it asked about. + const usageLimits = yield* Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe( + HttpClientRequest.bearerToken(token), + ), + ); + const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)( + yield* HttpClientResponse.filterStatusOk(response), + ); + return grokUsageResponseToLimits(body, checkedAt); + }).pipe( + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => probeFailed), + ); + return { email, usageLimits }; +}); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 7884b9d1e1583c04e8ac37af89a5cb8921b2b153..389bf83f3f78dc85a30a6d085e1ed5bba70dce0a 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -18,7 +18,7 @@ import * as Semaphore from "effect/Semaphore"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerSettings from "../serverSettings.ts"; import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts"; -import type { ServerProviderShape } from "./Services/ServerProvider.ts"; +import type { ServerProviderShape } from "./ServerProvider.ts"; interface ProviderSnapshotState { readonly snapshot: ServerProvider; diff --git a/apps/server/src/provider/openCodeUsageLimits.ts b/apps/server/src/provider/openCodeUsageLimits.ts new file mode 100644 index 0000000000000000000000000000000000000000..194f7be5c2fb8ffba90efbba9777bee086fc33de --- /dev/null +++ b/apps/server/src/provider/openCodeUsageLimits.ts @@ -0,0 +1,119 @@ +import * as NodeOS from "node:os"; +import * as NodeCrypto from "node:crypto"; + +import type { ServerProviderUsageWindow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; + +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "./providerUsageLimits.ts"; + +const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); +const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); +const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); +const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); +const UsageWindow = Schema.Struct({ + percent: Schema.Finite, + resetsAt: Schema.DateTimeUtcFromString, +}); +const UsageResponse = Schema.Struct({ + usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), +}); + +/** External OpenCode servers own their credentials; never read the host's account for them. */ +export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { + readonly enabled: boolean; + readonly serverUrl: string; + readonly environment: NodeJS.ProcessEnv; +}) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + if (!input.enabled || input.serverUrl.trim()) return unsupported; + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const env = input.environment; + const dataHome = + env.XDG_DATA_HOME || + path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share"); + const authPath = path.join(dataHome, "opencode", "auth.json"); + const contents = + env.OPENCODE_AUTH_CONTENT || + (yield* fs.readFileString(authPath).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + )); + const auth = yield* decodeAuthFile(contents); + const apiAuth = decodeApiAuth(auth["opencode-go"]); + // OpenCode overlays stored API credentials after environment credentials. + const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); + if (!apiKey) return unsupported; + + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe( + HttpClientRequest.bearerToken(apiKey), + ), + ); + // A valid Zen key can exist without a Go subscription. + if (response.status === 403) return unsupported; + const body = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)), + ); + const windows: ServerProviderUsageWindow[] = [ + { + id: "go_rolling", + kind: "session", + label: "Go · Session", + windowDurationMins: 5 * 60, + usedPercent: clampPercent(body.usage.rolling.percent), + resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt), + }, + { + id: "go_weekly", + kind: "weekly", + label: "Go · Weekly", + windowDurationMins: 7 * 24 * 60, + usedPercent: clampPercent(body.usage.weekly.percent), + resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt), + }, + { + id: "go_monthly", + kind: "monthly", + label: "Go · Monthly", + usedPercent: clampPercent(body.usage.monthly.percent), + resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), + }, + ]; + return { + ...makeUsageLimits({ checkedAt, windows }), + // Go's usage response has no account ID. An unkeyed hash matches across + // environments without a shared secret. It permits offline guesses, but + // Go keys are randomly generated. + credentialFingerprint: NodeCrypto.createHash("sha256") + .update("opencode-go\0") + .update(apiKey) + .digest("hex"), + }; + }).pipe( + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => + makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "OpenCode Go could not read usage.", + }), + ), + ); +}); diff --git a/apps/server/src/provider/piThinkingCapabilities.test.ts b/apps/server/src/provider/piThinkingCapabilities.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6474bf2d9ec400ae07f3020c3f0cdcf521189d9 --- /dev/null +++ b/apps/server/src/provider/piThinkingCapabilities.test.ts @@ -0,0 +1,52 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { thinkingCapabilitiesForPiModel } from "./piThinkingCapabilities.ts"; + +describe("thinkingCapabilitiesForPiModel", () => { + it("gives every choice its real level id and tags Pi's default", () => { + const capabilities = thinkingCapabilitiesForPiModel( + { + reasoning: true, + thinkingLevelMap: { off: null, xhigh: "extra_high", max: null }, + }, + "xhigh", + ); + const descriptors = capabilities.optionDescriptors ?? []; + const thinking = descriptors[0]; + assert.equal(thinking?.id, "thinking"); + assert.equal(thinking?.type, "select"); + if (thinking?.type !== "select") return; + assert.deepEqual( + thinking.options.map((option) => [option.id, option.label, option.isDefault === true]), + [ + ["minimal", "Minimal", false], + ["low", "Low", false], + ["medium", "Medium", false], + ["high", "High", false], + ["xhigh", "Extra High", true], + ], + ); + }); + + it("clamps Pi's default to each model's supported levels", () => { + const capabilities = thinkingCapabilitiesForPiModel( + { + reasoning: true, + thinkingLevelMap: { xhigh: "extra_high", max: null }, + }, + "max", + ); + const thinking = capabilities.optionDescriptors?.[0]; + assert.equal(thinking?.type, "select"); + if (thinking?.type !== "select") return; + assert.deepInclude(thinking.options, { + id: "xhigh", + label: "Extra High", + isDefault: true, + }); + assert.notInclude( + thinking.options.map((option) => option.id), + "max", + ); + }); +}); diff --git a/apps/server/src/provider/piThinkingCapabilities.ts b/apps/server/src/provider/piThinkingCapabilities.ts new file mode 100644 index 0000000000000000000000000000000000000000..0df30c7fe99cbf5002047d041035032f7e69168b --- /dev/null +++ b/apps/server/src/provider/piThinkingCapabilities.ts @@ -0,0 +1,97 @@ +import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Predicate from "effect/Predicate"; + +/** + * Pi's full thinking ladder. Extra High (`xhigh`) and Max are opt-in per + * model via `thinkingLevelMap`; advertising them globally makes + * `set_thinking_level` fail on models that lack them. + */ +const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; + +export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; + +const PI_THINKING_LEVEL_LABELS: Record = { + off: "Off", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; + +export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +export function thinkingCapabilitiesForPiModel( + model: unknown, + defaultThinkingLevel: unknown, +): ModelCapabilities { + const levels = supportedPiThinkingLevelsFromModel(model); + if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES; + const defaultLevel = clampPiThinkingLevel(defaultThinkingLevel, levels); + return createModelCapabilities({ + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + type: "select", + options: levels.map((level): ProviderOptionChoice => ({ + id: level, + label: PI_THINKING_LEVEL_LABELS[level], + ...(level === defaultLevel ? { isDefault: true } : {}), + })), + }, + ], + }); +} + +/** Mirrors `@earendil-works/pi-ai` `clampThinkingLevel`. */ +function clampPiThinkingLevel( + input: unknown, + availableLevels: ReadonlyArray, +): PiThinkingLevel | undefined { + if (typeof input !== "string") return undefined; + const requestedIndex = PI_THINKING_LEVELS.findIndex((level) => level === input); + if (requestedIndex === -1) return undefined; + const exact = availableLevels.find((level) => level === input); + if (exact !== undefined) return exact; + for (let index = requestedIndex + 1; index < PI_THINKING_LEVELS.length; index += 1) { + const higher = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); + if (higher !== undefined) return higher; + } + for (let index = requestedIndex - 1; index >= 0; index -= 1) { + const lower = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); + if (lower !== undefined) return lower; + } + return availableLevels[0]; +} + +/** + * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`. + * + * A reasoning model always exposes off through high unless a map entry is + * `null`. Extra High and Max appear only when the map has a non-null entry. + */ +function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray { + if (recordField(model, "reasoning") !== true) return []; + const thinkingLevelMap = thinkingLevelMapFromModel(model); + return PI_THINKING_LEVELS.filter((level) => { + const mapped = thinkingLevelMap?.[level]; + if (mapped === null) return false; + if (level === "xhigh" || level === "max") return mapped !== undefined; + return true; + }); +} + +function thinkingLevelMapFromModel(model: unknown): Record | undefined { + const value = recordField(model, "thinkingLevelMap"); + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function recordField(input: unknown, key: string): unknown { + return Predicate.isObject(input) ? input[key] : undefined; +} diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 5913cd776e1efccfb2630121f503b7f4ba4a316e..719a25042cae10dd8006850f8c1c2187c0f5f902 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -10,9 +10,8 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as ServerConfig from "../config.ts"; import * as ModelManifest from "./ModelManifest.ts"; -import * as ProviderRegistryLayer from "./Layers/ProviderRegistry.ts"; -import * as ProviderRegistry from "./Services/ProviderRegistry.ts"; -import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "./ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts"; @@ -362,7 +361,7 @@ it.effect("a remote policy refresh preserves a newer health result on the regist assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); assert.strictEqual(updated?.status, "error"); assert.strictEqual(updated?.message, "Authentication failed"); - }).pipe(Effect.provide(ProviderRegistryLayer.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index 01eaf0367fed144840952663cee731f87048d762..61cfbf730976eace801146fca6311bfc0318b0a1 100644 --- a/apps/server/src/provider/providerInstallation.test.ts +++ b/apps/server/src/provider/providerInstallation.test.ts @@ -17,8 +17,8 @@ import * as CodexInstallation from "./CodexInstallation.ts"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import type { ProviderInstance } from "./ProviderDriver.ts"; import { makeProviderInstallation } from "./providerInstallation.ts"; -import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts"; -import * as ProviderRegistry from "./Services/ProviderRegistry.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; const instanceId = ProviderInstanceId.make("antigravity"); const driver = ProviderDriverKind.make("antigravity"); diff --git a/apps/server/src/provider/providerInstallation.ts b/apps/server/src/provider/providerInstallation.ts index 62931e2e041889d355d7b1d243466a6c36c4a1ab..6c8fe697b8a037edf643213044f01e1fc95ae8ef 100644 --- a/apps/server/src/provider/providerInstallation.ts +++ b/apps/server/src/provider/providerInstallation.ts @@ -15,9 +15,9 @@ import * as Stream from "effect/Stream"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; -import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; -import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts"; -import * as ProviderRegistry from "./Services/ProviderRegistry.ts"; +import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; const ANTIGRAVITY = ProviderDriverKind.make("antigravity"); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index beac05fb2681801f2a7942c450858ed333454d4a..c82ec00143f2e403314fbd85ca6a9205a993783e 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -20,7 +20,7 @@ import { ChildProcessSpawner } from "effect/process"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; -import * as ProviderRegistry from "./Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { @@ -190,7 +190,7 @@ function makeRegistry( ); }); - const registry: ProviderRegistry.ProviderRegistryShape = { + const registry: ProviderRegistry.ProviderRegistry["Service"] = { getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), @@ -210,7 +210,7 @@ function makeRegistry( } const makeTestRunner = ( - registry: ProviderRegistry.ProviderRegistryShape, + registry: ProviderRegistry.ProviderRegistry["Service"], // Generic updater fixtures use synthetic versions. Keep their compatibility // unknown so real harness minimums do not bypass the command under test. manifest: ModelManifest.ModelManifestData = { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index b47befabe08f0af6b87768c59743807e8045523d..1e22ba899bec009dd68ec25ea6e41f4dbb936531 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -23,7 +23,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as ModelManifest from "./ModelManifest.ts"; import { resolveProviderCompatibility } from "./providerCompatibility.ts"; -import * as ProviderRegistry from "./Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; import { enrichProviderSnapshotWithVersionAdvisory, diff --git a/apps/server/src/provider/resetCreditCoordinator.test.ts b/apps/server/src/provider/resetCreditCoordinator.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..88de498d43dc3578e4fdc8d71b7d5ac673072dcb --- /dev/null +++ b/apps/server/src/provider/resetCreditCoordinator.test.ts @@ -0,0 +1,105 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; + +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; + +describe("ResetCreditCoordinator", () => { + it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const keys = yield* Ref.make>([]); + const attempts = yield* Ref.make(0); + const consume = (key: string) => + Effect.gen(function* () { + yield* Ref.update(keys, (seen) => [...seen, key]); + const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1); + if (attempt === 1) return yield* Effect.fail("timed out" as const); + return "reset" as const; + }); + + const first = yield* redeem("acct", consume).pipe(Effect.result); + assert.isTrue(first._tag === "Failure"); + const second = yield* redeem("acct", consume); + assert.strictEqual(second, "reset"); + // A fresh redemption after success must be a fresh attempt. + yield* redeem("acct", consume); + + const seen = yield* Ref.get(keys); + assert.strictEqual(seen.length, 3); + assert.strictEqual(seen[0], seen[1]); + assert.notStrictEqual(seen[1], seen[2]); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); + + it.effect("starts a fresh attempt after a settled failure", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const keys = yield* Ref.make>([]); + const consume = (key: string) => + Ref.update(keys, (seen) => [...seen, key]).pipe( + Effect.andThen(Effect.fail("cooldown" as const)), + ); + const isSettled = (error: "cooldown") => error === "cooldown"; + + yield* redeem("acct", consume, isSettled).pipe(Effect.result); + yield* redeem("acct", consume, isSettled).pipe(Effect.result); + + const seen = yield* Ref.get(keys); + assert.strictEqual(seen.length, 2); + assert.notStrictEqual(seen[0], seen[1]); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); + + it.effect("serialises concurrent redemptions on the same account, not per caller", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const release = yield* Deferred.make(); + const inFlight = yield* Ref.make(0); + const peak = yield* Ref.make(0); + const consume = () => + Effect.gen(function* () { + const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); + yield* Ref.update(peak, (p) => Math.max(p, now)); + yield* Deferred.await(release); + yield* Ref.update(inFlight, (n) => n - 1); + return "reset" as const; + }); + + // Two instances of the same account redeem at once. + const a = yield* redeem("acct", consume).pipe(Effect.forkChild); + const b = yield* redeem("acct", consume).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(a); + yield* Fiber.join(b); + + assert.strictEqual(yield* Ref.get(peak), 1); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); + + it.effect("keeps different accounts independent", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const release = yield* Deferred.make(); + const peak = yield* Ref.make(0); + const inFlight = yield* Ref.make(0); + const consume = () => + Effect.gen(function* () { + const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); + yield* Ref.update(peak, (p) => Math.max(p, now)); + yield* Deferred.await(release); + return "reset" as const; + }); + const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild); + const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(a); + yield* Fiber.join(b); + assert.strictEqual(yield* Ref.get(peak), 2); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); +}); diff --git a/apps/server/src/provider/resetCreditCoordinator.ts b/apps/server/src/provider/resetCreditCoordinator.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7a772e0c6f2b00baa5cdc0699dc833e6a588be5 --- /dev/null +++ b/apps/server/src/provider/resetCreditCoordinator.ts @@ -0,0 +1,111 @@ +/** + * Redeeming a reset credit is an account-level action: instances that share + * the directory holding a provider's login share the credit, so their + * redemptions must serialise on that directory, not the instance. This + * service keeps one lock and one pending idempotency key per account key so + * overlapping confirmations from any instance queue rather than spending two + * credits, and a retry after a timeout re-sends the same attempt. + * + * @module provider/resetCreditCoordinator + */ +import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; + +interface AccountRedemptionState { + readonly lock: Semaphore.Semaphore; + readonly pendingKey: Ref.Ref; +} + +export class ResetCreditCoordinator extends Context.Service< + ResetCreditCoordinator, + { + /** + * Run `consume` under the account's lock with a stable idempotency key. + * The key is cleared when the provider reports an outcome, or when + * `isSettled` says a failure was a final answer (such as a cooldown). + * Any other failure (timeout included) keeps it so the next attempt is + * the same attempt. + */ + readonly redeem: ( + accountKey: string, + consume: (idempotencyKey: string) => Effect.Effect, + isSettled?: (error: E) => boolean, + ) => Effect.Effect; + } +>()("t3/provider/resetCreditCoordinator") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const statesRef = yield* Ref.make>(new Map()); + + // Get-or-create through one Ref.modify so two first redemptions for the + // same account cannot each install their own lock. + const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) { + const existing = (yield* Ref.get(statesRef)).get(accountKey); + if (existing) return existing; + const candidate = { + lock: yield* Semaphore.make(1), + pendingKey: yield* Ref.make(null), + }; + return yield* Ref.modify(statesRef, (states) => { + const current = states.get(accountKey); + if (current) return [current, states] as const; + const next = new Map(states); + next.set(accountKey, candidate); + return [candidate, next] as const; + }); + }); + + const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) => + Effect.gen(function* () { + const state = yield* stateFor(accountKey); + return yield* state.lock.withPermits(1)( + Effect.gen(function* () { + const existing = yield* Ref.get(state.pendingKey); + const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4); + yield* Ref.set(state.pendingKey, idempotencyKey); + const outcome = yield* consume(idempotencyKey).pipe( + Effect.tapError((error) => + isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, + ), + ); + yield* Ref.set(state.pendingKey, null); + return outcome; + }), + ); + }); + + return { redeem } satisfies ResetCreditCoordinator["Service"]; +}); + +export const layer = Layer.effect(ResetCreditCoordinator, make); + +/** + * Self-contained for tests: a counter-backed Crypto so keys are deterministic + * and distinct without the platform layer. + */ +export const layerTest = Layer.effect( + ResetCreditCoordinator, + Effect.gen(function* () { + let counter = 0; + return yield* make.pipe( + Effect.provideService( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => { + counter += 1; + return new Uint8Array(size).fill(counter); + }, + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + ); + }), +); diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 5523872768d17658e0b17b65973f04054fcff3b2..aa27bc94608c6fbcb1ed8ad22772596ec789fa9d 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -1,4 +1,4 @@ -import * as ProviderRegistry 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 const makeProviderRegistryMock = ( providers: ReadonlyArray = [], -): ProviderRegistry.ProviderRegistryShape => ({ +): ProviderRegistry.ProviderRegistry["Service"] => ({ getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), diff --git a/apps/server/src/provider/testing/ClaudeCapabilitiesProbe.fixture.mjs b/apps/server/src/provider/testing/ClaudeCapabilitiesProbe.fixture.mjs new file mode 100755 index 0000000000000000000000000000000000000000..57fc1795cb65e3601198f3abef3d7ad37ee83f43 --- /dev/null +++ b/apps/server/src/provider/testing/ClaudeCapabilitiesProbe.fixture.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; +const args = process.argv.slice(2); +const mcpConfigIndex = args.indexOf("--mcp-config"); +const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined; +let mcpConfig; +if (rawMcpConfig) { + const contents = NodeFS.existsSync(rawMcpConfig) + ? NodeFS.readFileSync(rawMcpConfig, "utf8") + : rawMcpConfig; + try { + mcpConfig = JSON.parse(contents); + } catch { + mcpConfig = contents; + } +} +NodeFS.writeFileSync( + process.env.T3_PROBE_INVOCATION_PATH, + JSON.stringify({ + args, + cwd: process.cwd(), + connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS, + mcpConfig, + }), +); +const lines = NodeReadline.createInterface({ input: process.stdin }); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.type !== "control_request") return; + const reply = (response) => + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { subtype: "success", request_id: message.request_id, response }, + }) + "\n", + ); + if (message.request?.subtype === "initialize") { + reply({ + commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], + agents: [], + output_style: "default", + available_output_styles: ["default"], + models: [], + account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + }); + } + // The probe follows initialize with get_usage on the same process. + if (message.request?.subtype === "get_usage") { + reply({ + session: {}, + subscription_type: "pro", + rate_limits_available: true, + rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, + behaviors: null, + }); + } +}); +// Stay alive for follow-up control requests, but never outlive the +// parent: the probe aborts the SDK without awaiting the child, so an +// unconditional interval would strand this process until reboot. +const keepAlive = setInterval(() => {}, 1_000); +lines.on("close", () => { + clearInterval(keepAlive); + process.exit(0); +}); diff --git a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs new file mode 100755 index 0000000000000000000000000000000000000000..054ca04703e5149b7219d45708e0c3ed824557bf --- /dev/null +++ b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; +if (process.argv.includes("--version")) { + process.stdout.write("claude 2.1.219\n"); + process.exit(0); +} +const lines = NodeReadline.createInterface({ input: process.stdin }); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.type !== "control_request") return; + if (message.request?.subtype === "get_usage") { + const marker = process.env.T3_CLAUDE_RESET_MARKER; + if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && NodeFS.existsSync(marker)) { + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { subtype: "error", request_id: message.request_id, error: "usage failed" }, + }) + "\n", + ); + return; + } + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { + subtype: "success", + request_id: message.request_id, + response: { + session: {}, + subscription_type: "pro", + rate_limits_available: true, + rate_limits: { + five_hour: { + utilization: marker && NodeFS.existsSync(marker) ? 0 : 100, + resets_at: null, + }, + }, + }, + }, + }) + "\n", + ); + return; + } + if (message.request?.subtype !== "initialize") return; + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { + subtype: "success", + request_id: message.request_id, + response: { + commands: [], + agents: [], + models: [], + output_style: "default", + available_output_styles: ["default"], + account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + }, + }, + }) + "\n", + ); +}); +// Stay alive for follow-up control requests, but never outlive the +// parent: the probe aborts the SDK without awaiting the child, so an +// unconditional interval would strand this process until reboot. +const keepAlive = setInterval(() => {}, 1_000); +lines.on("close", () => { + clearInterval(keepAlive); + process.exit(0); +}); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index a8fac02fd52629aa8cbe9912beefd309c23611e4..d8fd366916ab14fd20a015754b0f662b6fe8c086 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -25,7 +25,7 @@ import * as ServerSettingsService from "../serverSettings.ts"; import { PullRequestOperationError } from "@t3tools/contracts"; import * as ProjectService from "../project/ProjectService.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; diff --git a/apps/server/src/pullRequest/linkedThreads.test.ts b/apps/server/src/pullRequest/linkedThreads.test.ts index 021ef941a9a1e11a4409b21a79e0105d236d9ebb..8b1d2764202917b44df81cec58cf41b7f312a32c 100644 --- a/apps/server/src/pullRequest/linkedThreads.test.ts +++ b/apps/server/src/pullRequest/linkedThreads.test.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { listLinkedPullRequestThreads } from "./linkedThreads.ts"; const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 82f175ead53f6e70bc00c8b1dc93af288c980476..96a197fa05d171f26207f93d7f3a2582bf714ffc 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -11,7 +11,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index dc55c9ce3cf591fb15ba58cff99a0eb66d73af8d..1c95fecdea7954915088a05c97a9ac1deb3d8527 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -18,7 +18,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; const isScheduledTaskError = Schema.is(ScheduledTaskError); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index e19ca8c16bf135ae118e0fc0d4b3f4119ec39ca6..310baf135b8f45ea2fff7343bc8a1349e50ee10b 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -17,7 +17,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; diff --git a/apps/server/src/scheduling/Scheduler.integration.test.ts b/apps/server/src/scheduling/Scheduler.integration.test.ts index 251ff16c8f10fd309030e5134a88a9add368f03f..1409697a7988671d32f949905d92dd517a42f312 100644 --- a/apps/server/src/scheduling/Scheduler.integration.test.ts +++ b/apps/server/src/scheduling/Scheduler.integration.test.ts @@ -22,7 +22,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 70173156909f4ca8c6cdf2fb7eb5b7fb82c5308f..867e5c89097767d984bdf1c0447c6527fb699d29 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -35,17 +35,17 @@ import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; import * as PullRequestHttp from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; -import * as SqlitePersistence from "./persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "./persistence/Sqlite.ts"; import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; -import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts"; -import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; +import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; +import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; import * as OpenCodeServerLedger from "./provider/OpenCodeServerLedger.ts"; -import * as AcpRegistryCatalog from "./provider/Layers/AcpRegistryCatalog.ts"; +import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -54,7 +54,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; -import * as ProviderInstanceRegistryHydration from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; +import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -70,16 +70,15 @@ import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; -import * as ProviderRegistryLayer from "./provider/Layers/ProviderRegistry.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts"; import * as CodexInstallation from "./provider/CodexInstallation.ts"; -import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; import * as ProviderAdapterRegistry from "./orchestration-v2/ProviderAdapterRegistry.ts"; -import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; -import * as ProviderUsageLimitsIngestion from "./provider/Layers/ProviderUsageLimitsIngestion.ts"; +import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; +import * as ProviderUsageLimitsIngestion from "./provider/ProviderUsageLimitsIngestion.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; @@ -101,7 +100,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; -import * as Observability from "./observability/Layers/Observability.ts"; +import * as Observability from "./observability/Observability.ts"; import * as HeapSnapshot from "./observability/HeapSnapshot.ts"; import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -569,7 +568,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge( Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer), ), - Layer.provideMerge(ProviderRegistryLayer.layer), + 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`; @@ -601,7 +600,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer), ), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old - // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but + // `ProviderRegistry.layer` pulled `OpenCodeRuntimeLive` 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. diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 2ac04c9663b640df33ce4961d2c927afdb037fe0..c3a4357afdc4c7d598545e956f8bd7ed43a42642 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -27,7 +27,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; -import * as SqlitePersistence from "./persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "./persistence/Sqlite.ts"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index f873f0885dbc864fd72f5cb3385e4481fb49ec6b..ad763bff2f8cb08fb126982d33e34305385818d4 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -37,7 +37,7 @@ import { expect } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; -import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index e98ff0349ec1f702886a3e6ffa172c096dfe617a..f1807f2d99ded1063fc35b6e6b8cc8e3267e0451 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -65,7 +65,7 @@ import * as ServerConfig from "../config.ts"; import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; -import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ServerSettings from "../serverSettings.ts"; import { increment, diff --git a/apps/server/src/testUtils/shardWeights.json b/apps/server/src/testUtils/shardWeights.json index 999edad92545135aa76f50517fdc1ef9d3b0cf31..a89b14fdb9ec02db15454ba7b50ad54a76d1931e 100644 --- a/apps/server/src/testUtils/shardWeights.json +++ b/apps/server/src/testUtils/shardWeights.json @@ -31,16 +31,16 @@ "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 25.4, "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 3.7, "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 4.3, - "src/persistence/Layers/OrchestrationEventStore.test.ts": 1.9, - "src/persistence/Layers/Sqlite.test.ts": 0.5, + "src/persistence/OrchestrationEventStore.test.ts": 1.9, + "src/persistence/Sqlite.test.ts": 0.5, "src/process/externalLauncher.test.ts": 2.3, "src/project/AgentSessionScanner.test.ts": 4.2, "src/project/ManagedProjectFolders.test.ts": 0.5, "src/provider/CodexChatGptAuth.test.ts": 5.4, "src/provider/Drivers/AntigravityDriver.test.ts": 2.3, - "src/provider/Layers/GrokProvider.test.ts": 0.9, - "src/provider/Layers/ProviderInstanceRegistryLive.test.ts": 0.5, + "src/provider/GrokProvider.test.ts": 0.9, "src/provider/OpenCodeServerLedger.test.ts": 1.7, + "src/provider/ProviderInstanceRegistry.test.ts": 0.5, "src/provider/acp/AcpClientTerminals.test.ts": 1.3, "src/provider/acp/AcpJsonRpcConnection.test.ts": 10.2, "src/provider/acp/AcpRegistryProbe.test.ts": 1, diff --git a/apps/server/src/testkit/RpcInitialMemory.fixture.mjs b/apps/server/src/testkit/RpcInitialMemory.fixture.mjs index 8ebb3b67ea7dd3e74ed8da90ecf817e05c9d254e..a9648abf671551ae773a9f1814c1d1099090e38a 100644 --- a/apps/server/src/testkit/RpcInitialMemory.fixture.mjs +++ b/apps/server/src/testkit/RpcInitialMemory.fixture.mjs @@ -17,7 +17,7 @@ const [Prefix, Ws, Threads, Events] = await Promise.all([ app("rpcInitialItems"), app("ws"), app("orchestration-v2/ThreadManagementService"), - app("persistence/Services/OrchestrationEventStore"), + app("persistence/OrchestrationEventStore"), ]); const refs = []; const fibers = []; diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 046aae99965476e4faf89c5c0fe5e4954a2778b6..2cbaaef19d8a5214b9bd6fae607a071932f0b696 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -20,7 +20,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; -import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts"; +import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/codexLaunchArgs.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index cb0bc151ead30d28aad4fc7fd5044b2b841554c0..ff95b780c31d8bafb094c52a39be217dbcf37297 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"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as Layer from "effect/Layer"; diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index a4f4db1d90443679a241269c6f2baada0e666a58..f6ef94290db9cbe92b38d9b7465d9cac6b121f79 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -9,7 +9,7 @@ import type { } 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"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ThreadTitleLinks from "./ThreadTitleLinks.ts"; diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 04307f21282b8b248d1342c918165a4ffa8d4921..8bfb66c6e3799749ca56a1d3deccab5ad34bc75f 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -12,9 +12,9 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { codexPlanLabel } from "../provider/Layers/CodexProvider.ts"; -import { codexRateLimitsToLimits } from "../provider/Layers/codexUsageLimits.ts"; -import { claudeUsageResponseToLimits } from "../provider/Layers/claudeUsageLimits.ts"; +import { codexPlanLabel } from "../provider/CodexProvider.ts"; +import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; +import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; import { makeUnavailableUsageLimits } from "../provider/providerUsageLimits.ts"; const AuthFile = Schema.Struct({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 002da7178f7379dc8c9cf77e1c45686a36d6548a..96844f8cfd7933a01079957e4fab08769ae916b3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -158,21 +158,21 @@ import { } from "./orchestration-v2/WireProjection.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; -import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts"; +import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; import { observeRpcEffect, observeRpcStream, observeRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; -import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; -import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; +import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts"; import * as AcpRegistryRuntimeCoordinator from "./provider/acp/AcpRegistryRuntimeCoordinator.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderMaintenance from "./provider/providerMaintenance.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; -import * as ProviderAuthService from "./provider/Services/ProviderAuthService.ts"; +import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a619fbd0dcec2ba226dfc4ce626edddfc6de492d..ca1973438b8dc12c4d0dd1b04a61b441083d747a 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -52,7 +52,7 @@ and removal must respect those leases instead of replacing executables under a r ## Setup must not happen as a health-check side effect Opening a provider session can start MCP servers, run hooks, or launch a login browser. -[Grok probes](../../apps/server/src/provider/Layers/GrokProvider.ts) avoid authentication and +[Grok probes](../../apps/server/src/provider/GrokProvider.ts) avoid authentication and session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for explicit setup or model refresh; background checks use initialization only. diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 3e083c269a7dc4ae3f5970dcaa10a88e8a612c21..b0df7bae90417e639df4f8cc9972518ae970e3af 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -552,7 +552,7 @@ const program = doWork().pipe( ### Runtime Wiring -The server observability layer is assembled in `apps/server/src/observability/Layers/Observability.ts`. +The server observability layer is assembled in `apps/server/src/observability/Observability.ts`. It provides: